Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa8a0cc512 | ||
|
|
d18fffe665 | ||
|
|
aece653970 | ||
|
|
2b1cf6ec13 | ||
|
|
38355c9507 | ||
|
|
d0c5d65f0e | ||
|
|
4f9bf2dec4 | ||
|
|
4ee047311f | ||
|
|
41b6dc8f10 |
@@ -12,19 +12,13 @@ on:
|
|||||||
description: "Existing protected macos-vMAJOR.MINOR.PATCH tag on main"
|
description: "Existing protected macos-vMAJOR.MINOR.PATCH tag on main"
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
mode:
|
|
||||||
description: "Build a release or resume publishing existing verified artifacts"
|
|
||||||
required: true
|
|
||||||
default: build
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- build
|
|
||||||
- resume
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
|
# Partial release files may be replaced until the feed is promoted.
|
||||||
|
# Keep every publisher in this group to prevent overlapping rebuilds.
|
||||||
group: macos-release
|
group: macos-release
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
@@ -57,10 +51,8 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
REQUESTED_TAG: ${{ inputs.tag }}
|
REQUESTED_TAG: ${{ inputs.tag }}
|
||||||
MACOS_RUNNER: ${{ vars.MACOS_RUNNER }}
|
MACOS_RUNNER: ${{ vars.MACOS_RUNNER }}
|
||||||
RELEASE_MODE: ${{ inputs.mode || 'build' }}
|
|
||||||
run: |
|
run: |
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
[[ "${RELEASE_MODE:-build}" == build || "${RELEASE_MODE:-build}" == resume ]] || { echo 'Invalid release mode.' >&2; exit 1; }
|
|
||||||
if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then
|
if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then
|
||||||
[[ "$GITHUB_REF" == refs/heads/main ]] || { echo 'Dispatch this workflow from main.' >&2; exit 1; }
|
[[ "$GITHUB_REF" == refs/heads/main ]] || { echo 'Dispatch this workflow from main.' >&2; exit 1; }
|
||||||
tag="$REQUESTED_TAG"
|
tag="$REQUESTED_TAG"
|
||||||
@@ -90,7 +82,6 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
name: Sign, notarize, verify and publish macOS
|
name: Sign, notarize, verify and publish macOS
|
||||||
needs: prepare
|
needs: prepare
|
||||||
if: ${{ inputs.mode != 'resume' }}
|
|
||||||
# Gitea resolves runner labels before outputs from prepare are available.
|
# Gitea resolves runner labels before outputs from prepare are available.
|
||||||
runs-on: ${{ vars.MACOS_RUNNER }}
|
runs-on: ${{ vars.MACOS_RUNNER }}
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
@@ -209,7 +200,7 @@ jobs:
|
|||||||
id: package
|
id: package
|
||||||
run: node native/macos-ci.mjs package
|
run: node native/macos-ci.mjs package
|
||||||
|
|
||||||
- name: Publish immutable files and verify public Hetzner downloads
|
- name: Upload release files and promote the verified update feed
|
||||||
env:
|
env:
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
||||||
@@ -235,42 +226,3 @@ jobs:
|
|||||||
finally:
|
finally:
|
||||||
shutil.rmtree(directory)
|
shutil.rmtree(directory)
|
||||||
PY
|
PY
|
||||||
|
|
||||||
resume:
|
|
||||||
name: Resume verified release publication
|
|
||||||
needs: prepare
|
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.mode == 'resume' }}
|
|
||||||
runs-on: ${{ vars.MACOS_RUNNER }}
|
|
||||||
timeout-minutes: 15
|
|
||||||
env:
|
|
||||||
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
|
|
||||||
RELEASE_METADATA_DIRECTORY: release-source/native
|
|
||||||
steps:
|
|
||||||
- name: Check out current publishing tools
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ github.sha }}
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Check out validated release metadata
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ needs.prepare.outputs.commit }}
|
|
||||||
path: release-source
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Verify existing artifacts and resume publication
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_EC2_METADATA_DISABLED: "true"
|
|
||||||
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
|
|
||||||
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
|
|
||||||
run: |
|
|
||||||
aws --version | grep '^aws-cli/2\.' || { echo 'Install AWS CLI v2 permanently on this runner.' >&2; exit 1; }
|
|
||||||
node native/publish-macos.mjs resume
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kanbangit-native",
|
"name": "kanbangit-native",
|
||||||
"version": "0.1.4",
|
"version": "0.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -24,5 +24,5 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "2.11.4"
|
"@tauri-apps/cli": "2.11.4"
|
||||||
},
|
},
|
||||||
"releaseNotes": "About KanbanGit and Software updates show the installed app build number correctly. Release publishing now handles Hetzner responses with empty error messages when updating the feed."
|
"releaseNotes": "Add the stable Mac download for the KanbanGit website."
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-71
@@ -73,7 +73,7 @@ export function conditionalEtag(etag) {
|
|||||||
!/^[a-f\d]{32}(?:-[1-9]\d*)?$/i.test(value)
|
!/^[a-f\d]{32}(?:-[1-9]\d*)?$/i.test(value)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Cannot atomically update the feed without a valid S3 ETag.',
|
'Cannot atomically replace an object without a valid S3 ETag.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Hetzner returns quoted ETags on GET but compares bare values on conditional PUT.
|
// Hetzner returns quoted ETags on GET but compares bare values on conditional PUT.
|
||||||
@@ -185,12 +185,15 @@ export async function publishMacos({
|
|||||||
const staging = mkdtempSync(join(tmpdir(), 'kanbangit-s3-'));
|
const staging = mkdtempSync(join(tmpdir(), 'kanbangit-s3-'));
|
||||||
const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`;
|
const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`;
|
||||||
const publicPrefix = `${storage.publicBase}/${keyPrefix}`;
|
const publicPrefix = `${storage.publicBase}/${keyPrefix}`;
|
||||||
|
const latestPrefix = `${storage.prefix}/${metadata.target}`;
|
||||||
|
const latestKey = `${latestPrefix}/latest.json`;
|
||||||
async function verifyPublic(name, contents, prefix = publicPrefix) {
|
async function verifyPublic(name, contents, prefix = publicPrefix) {
|
||||||
for (let attempt = 1; attempt <= 5; attempt++) {
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await fetchPublic(`${prefix}/${name}`, {
|
response = await fetchPublic(`${prefix}/${name}`, {
|
||||||
redirect: 'error',
|
redirect: 'error',
|
||||||
|
cache: 'no-store',
|
||||||
signal: AbortSignal.timeout(60000),
|
signal: AbortSignal.timeout(60000),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -225,36 +228,90 @@ export async function publishMacos({
|
|||||||
await wait(2000);
|
await wait(2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function putImmutable(name, contents, contentType) {
|
function assertUnpublishedRelease(allowSameVersion = false) {
|
||||||
|
const file = join(staging, 'published-latest.json');
|
||||||
|
try {
|
||||||
|
s3('get-object', ['--key', latestKey, file]);
|
||||||
|
} catch (error) {
|
||||||
|
if (/\(NoSuchKey\)/.test(error.message)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requiresExistingObjectCheck(error)) {
|
||||||
|
// A broken CLI error message cannot distinguish missing from denied.
|
||||||
|
// Only an authenticated, explicitly empty listing proves absence.
|
||||||
|
const listing = JSON.parse(
|
||||||
|
s3('list-objects-v2', [
|
||||||
|
'--prefix',
|
||||||
|
latestKey,
|
||||||
|
'--max-keys',
|
||||||
|
'1',
|
||||||
|
'--output',
|
||||||
|
'json',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
listing?.KeyCount === 0 &&
|
||||||
|
listing.IsTruncated === false &&
|
||||||
|
Array.isArray(listing.Contents ?? []) &&
|
||||||
|
(listing.Contents ?? []).length === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const published = JSON.parse(readFileSync(file, 'utf8'));
|
||||||
|
const comparison = compareVersions(metadata.version, published.version);
|
||||||
|
if (allowSameVersion && comparison < 0) {
|
||||||
|
throw new Error('Refusing to downgrade the stable download.');
|
||||||
|
}
|
||||||
|
if (comparison < 0 || (!allowSameVersion && comparison === 0)) {
|
||||||
|
throw new Error(
|
||||||
|
'This version has already been released through the update feed; bump the app version. Published objects were not overwritten.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The macos-release concurrency group serializes the entire publication.
|
||||||
|
// Never run independent publishers against the same release destination.
|
||||||
|
function putReleaseFile(name, contents, contentType) {
|
||||||
const file = join(staging, name);
|
const file = join(staging, name);
|
||||||
writeFileSync(file, contents);
|
writeFileSync(file, contents);
|
||||||
try {
|
function put(condition) {
|
||||||
s3('put-object', [
|
s3('put-object', [
|
||||||
'--key',
|
'--key',
|
||||||
`${keyPrefix}/${name}`,
|
`${keyPrefix}/${name}`,
|
||||||
'--body',
|
'--body',
|
||||||
file,
|
file,
|
||||||
'--if-none-match',
|
...condition,
|
||||||
'*',
|
|
||||||
'--content-type',
|
'--content-type',
|
||||||
contentType,
|
contentType,
|
||||||
'--cache-control',
|
'--cache-control',
|
||||||
'public, max-age=31536000, immutable',
|
'no-cache, max-age=0, must-revalidate',
|
||||||
'--content-md5',
|
'--content-md5',
|
||||||
createHash('md5').update(contents).digest('base64'),
|
createHash('md5').update(contents).digest('base64'),
|
||||||
'--metadata',
|
'--metadata',
|
||||||
`sha256=${sha256(contents)}`,
|
`sha256=${sha256(contents)}`,
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
put(['--if-none-match', '*']);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!requiresExistingObjectCheck(error)) {
|
if (!requiresExistingObjectCheck(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const existing = join(staging, `existing-${name}`);
|
const existing = join(staging, `existing-${name}`);
|
||||||
s3('get-object', ['--key', `${keyPrefix}/${name}`, existing]);
|
const response = JSON.parse(
|
||||||
|
s3('get-object', [
|
||||||
|
'--key',
|
||||||
|
`${keyPrefix}/${name}`,
|
||||||
|
'--output',
|
||||||
|
'json',
|
||||||
|
existing,
|
||||||
|
]),
|
||||||
|
);
|
||||||
if (sha256(readFileSync(existing)) !== sha256(contents)) {
|
if (sha256(readFileSync(existing)) !== sha256(contents)) {
|
||||||
throw new Error(
|
assertUnpublishedRelease();
|
||||||
`Published ${name} differs; bump the app version. Existing objects were not overwritten.`,
|
put(['--if-match', conditionalEtag(response.ETag)]);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,7 +336,7 @@ export async function publishMacos({
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
for (const [name, contents, type] of files) {
|
for (const [name, contents, type] of files) {
|
||||||
putImmutable(name, contents, type);
|
putReleaseFile(name, contents, type);
|
||||||
await verifyPublic(name, contents);
|
await verifyPublic(name, contents);
|
||||||
}
|
}
|
||||||
// Completion metadata is written only after every public artifact matches.
|
// Completion metadata is written only after every public artifact matches.
|
||||||
@@ -296,7 +353,7 @@ export async function publishMacos({
|
|||||||
2,
|
2,
|
||||||
) + '\n',
|
) + '\n',
|
||||||
);
|
);
|
||||||
putImmutable('release.json', manifest, 'application/json');
|
putReleaseFile('release.json', manifest, 'application/json');
|
||||||
await verifyPublic('release.json', manifest);
|
await verifyPublic('release.json', manifest);
|
||||||
const latest = Buffer.from(
|
const latest = Buffer.from(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
@@ -310,10 +367,57 @@ export async function publishMacos({
|
|||||||
2,
|
2,
|
||||||
) + '\n',
|
) + '\n',
|
||||||
);
|
);
|
||||||
putImmutable('update.json', latest, 'application/json');
|
putReleaseFile('update.json', latest, 'application/json');
|
||||||
await verifyPublic('update.json', latest);
|
await verifyPublic('update.json', latest);
|
||||||
const latestPrefix = `${storage.prefix}/${metadata.target}`;
|
// An older release must not populate an absent download alias either.
|
||||||
const latestKey = `${latestPrefix}/latest.json`;
|
assertUnpublishedRelease(true);
|
||||||
|
const downloadName = 'KanbanGit.dmg';
|
||||||
|
const downloadKey = `${latestPrefix}/${downloadName}`;
|
||||||
|
const downloadFile = join(staging, downloadName);
|
||||||
|
writeFileSync(downloadFile, archive);
|
||||||
|
function publishDownload(condition) {
|
||||||
|
s3('put-object', [
|
||||||
|
'--key',
|
||||||
|
downloadKey,
|
||||||
|
'--body',
|
||||||
|
downloadFile,
|
||||||
|
...condition,
|
||||||
|
'--content-type',
|
||||||
|
'application/x-apple-diskimage',
|
||||||
|
'--cache-control',
|
||||||
|
'no-cache, max-age=0, must-revalidate',
|
||||||
|
'--content-md5',
|
||||||
|
createHash('md5').update(archive).digest('base64'),
|
||||||
|
'--metadata',
|
||||||
|
`sha256=${checksum}`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
publishDownload(['--if-none-match', '*']);
|
||||||
|
} catch (error) {
|
||||||
|
if (!requiresExistingObjectCheck(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const previousFile = join(staging, 'previous-download.dmg');
|
||||||
|
const response = JSON.parse(
|
||||||
|
s3('get-object', [
|
||||||
|
'--key',
|
||||||
|
downloadKey,
|
||||||
|
'--output',
|
||||||
|
'json',
|
||||||
|
previousFile,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
if (sha256(readFileSync(previousFile)) !== checksum) {
|
||||||
|
assertUnpublishedRelease();
|
||||||
|
publishDownload(['--if-match', conditionalEtag(response.ETag)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await verifyPublic(
|
||||||
|
downloadName,
|
||||||
|
archive,
|
||||||
|
`${storage.publicBase}/${latestPrefix}`,
|
||||||
|
);
|
||||||
const latestFile = join(staging, 'latest.json');
|
const latestFile = join(staging, 'latest.json');
|
||||||
writeFileSync(latestFile, latest);
|
writeFileSync(latestFile, latest);
|
||||||
function publishLatest(condition) {
|
function publishLatest(condition) {
|
||||||
@@ -377,49 +481,6 @@ export async function publishMacos({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resumeMacos(options = {}) {
|
|
||||||
const env = options.env ?? process.env;
|
|
||||||
const metadata = options.metadata ?? releaseMetadata(env);
|
|
||||||
const storage = storageConfiguration(env);
|
|
||||||
const feedBase = options.feedBase ?? updateSource.baseUrl;
|
|
||||||
if (`${storage.publicBase}/${storage.prefix}` !== feedBase) {
|
|
||||||
throw new Error(
|
|
||||||
'Release storage does not match the update source bundled in the app.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-resume-'));
|
|
||||||
const fetchPublic = options.fetchPublic ?? fetch;
|
|
||||||
const prefix = `${feedBase}/${metadata.target}/${metadata.version}`;
|
|
||||||
try {
|
|
||||||
for (const name of [
|
|
||||||
metadata.filename,
|
|
||||||
`${metadata.filename}.sha256`,
|
|
||||||
metadata.updaterFilename,
|
|
||||||
`${metadata.updaterFilename}.sha256`,
|
|
||||||
`${metadata.updaterFilename}.sig`,
|
|
||||||
]) {
|
|
||||||
const response = await fetchPublic(`${prefix}/${name}`, {
|
|
||||||
redirect: 'error',
|
|
||||||
signal: AbortSignal.timeout(60000),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
await response.body?.cancel();
|
|
||||||
throw new Error(
|
|
||||||
`Cannot resume incomplete release: ${name} HTTP ${response.status}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
writeFileSync(
|
|
||||||
join(directory, name),
|
|
||||||
Buffer.from(await response.arrayBuffer()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Reuse signature, checksum, immutable content and feed concurrency checks.
|
|
||||||
return await publishMacos({ ...options, env, metadata, directory });
|
|
||||||
} finally {
|
|
||||||
rmSync(directory, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
process.argv[1] &&
|
process.argv[1] &&
|
||||||
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
||||||
@@ -428,20 +489,12 @@ if (
|
|||||||
if (process.argv[2] === 'config') {
|
if (process.argv[2] === 'config') {
|
||||||
console.log(JSON.stringify(storageConfiguration()));
|
console.log(JSON.stringify(storageConfiguration()));
|
||||||
} else if (
|
} else if (
|
||||||
process.argv[2] === 'resume' ||
|
process.argv[2] === 'upload' &&
|
||||||
(process.argv[2] === 'upload' && process.env.RELEASE_DIRECTORY)
|
process.env.RELEASE_DIRECTORY
|
||||||
) {
|
) {
|
||||||
const publish =
|
const url = await publishMacos({
|
||||||
process.argv[2] === 'resume' ? resumeMacos : publishMacos;
|
|
||||||
const url = await publish({
|
|
||||||
directory: process.env.RELEASE_DIRECTORY,
|
directory: process.env.RELEASE_DIRECTORY,
|
||||||
metadata: releaseMetadata(
|
metadata: releaseMetadata(),
|
||||||
process.env,
|
|
||||||
process.argv[2] === 'resume' &&
|
|
||||||
process.env.RELEASE_METADATA_DIRECTORY
|
|
||||||
? resolve(process.env.RELEASE_METADATA_DIRECTORY)
|
|
||||||
: undefined,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
console.log(`Verified public release: ${url}`);
|
console.log(`Verified public release: ${url}`);
|
||||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||||
@@ -452,7 +505,7 @@ if (
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Use config, upload or resume; upload requires RELEASE_DIRECTORY.',
|
'Use config or upload; upload requires RELEASE_DIRECTORY.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Generated
+1
-1
@@ -1770,7 +1770,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.1.4"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.1.4"
|
version = "0.2.1"
|
||||||
description = "KanbanGit native application"
|
description = "KanbanGit native application"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
}));
|
}));
|
||||||
let menu = MenuBuilder::new(app)
|
let menu = MenuBuilder::new(app)
|
||||||
.item(
|
.item(
|
||||||
&SubmenuBuilder::new(app, en::APP_NAME)
|
&SubmenuBuilder::with_id(app, "app-menu", en::APP_NAME)
|
||||||
.about_with_text(
|
.about_with_text(
|
||||||
en::ABOUT,
|
en::ABOUT,
|
||||||
Some(about_metadata(app.package_info().version.to_string())),
|
Some(about_metadata(app.package_info().version.to_string())),
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ pub use desktop::{builder, context};
|
|||||||
#[cfg(target_os = "ios")]
|
#[cfg(target_os = "ios")]
|
||||||
pub use ios::{builder, context};
|
pub use ios::{builder, context};
|
||||||
|
|
||||||
|
#[cfg(all(target_os = "macos", feature = "smoke-test"))]
|
||||||
|
pub use updates::refresh_menu as refresh_update_menu_for_smoke_test;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
builder().run(context()).expect("Could not start KanbanGit");
|
builder().run(context()).expect("Could not start KanbanGit");
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ pub mod en {
|
|||||||
pub const ABOUT: &str = "About KanbanGit";
|
pub const ABOUT: &str = "About KanbanGit";
|
||||||
pub const CHECK_UPDATES: &str = "Check For Updates…";
|
pub const CHECK_UPDATES: &str = "Check For Updates…";
|
||||||
pub const INSTALL_UPDATE: &str = "Install Update…";
|
pub const INSTALL_UPDATE: &str = "Install Update…";
|
||||||
pub const UPDATE_BADGE: &str = "Update";
|
|
||||||
pub const UPDATES: &str = "KanbanGit Updates";
|
pub const UPDATES: &str = "KanbanGit Updates";
|
||||||
pub const APP_NAME: &str = "KanbanGit";
|
pub const APP_NAME: &str = "KanbanGit";
|
||||||
pub const SERVER_SETTINGS: &str = "Server settings…";
|
pub const SERVER_SETTINGS: &str = "Server settings…";
|
||||||
|
|||||||
@@ -123,19 +123,28 @@ fn trusted_navigation(url: &Url) -> bool {
|
|||||||
&& url.username().is_empty()
|
&& url.username().is_empty()
|
||||||
&& url.password().is_none()
|
&& url.password().is_none()
|
||||||
}
|
}
|
||||||
|
pub fn refresh_menu(app: &AppHandle, available: bool, busy: bool) {
|
||||||
|
if let Some(menu) = app
|
||||||
|
.menu()
|
||||||
|
.and_then(|menu| menu.get("app-menu"))
|
||||||
|
.and_then(|item| {
|
||||||
|
item.as_submenu()
|
||||||
|
.and_then(|menu| menu.get("install-update"))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
if let Some(item) = menu.as_menuitem() {
|
||||||
|
let _ = item.set_enabled(available && !busy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
pub fn refresh(app: &AppHandle) {
|
pub fn refresh(app: &AppHandle) {
|
||||||
let (view, available, busy) = {
|
let (view, available, busy) = {
|
||||||
let state = app.state::<Mutex<State>>();
|
let state = app.state::<Mutex<State>>();
|
||||||
let state = state.lock().unwrap();
|
let state = state.lock().unwrap();
|
||||||
(state.view.clone(), state.update.is_some(), state.busy())
|
(state.view.clone(), state.update.is_some(), state.busy())
|
||||||
};
|
};
|
||||||
if let Some(menu) = app.menu().and_then(|m| m.get("install-update")) {
|
refresh_menu(app, available, busy);
|
||||||
if let Some(item) = menu.as_menuitem() {
|
|
||||||
let _ = item.set_enabled(available && !busy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(window) = app.get_webview_window("updates") {
|
if let Some(window) = app.get_webview_window("updates") {
|
||||||
let _ = window.set_badge_label(available.then(|| crate::locale::en::UPDATE_BADGE.into()));
|
|
||||||
if let Ok(json) = serde_json::to_string(&view) {
|
if let Ok(json) = serde_json::to_string(&view) {
|
||||||
let _ = window.eval(format!("window.updateStatus?.({json})"));
|
let _ = window.eval(format!("window.updateStatus?.({json})"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "KanbanGit",
|
"productName": "KanbanGit",
|
||||||
"version": "0.1.4",
|
"version": "0.2.1",
|
||||||
"identifier": "de.kanbangit.desktop",
|
"identifier": "de.kanbangit.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui"
|
"frontendDist": "../ui"
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
],
|
],
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"minimumSystemVersion": "14.0",
|
"minimumSystemVersion": "14.0",
|
||||||
"bundleVersion": "4",
|
"bundleVersion": "7",
|
||||||
"dmg": {
|
"dmg": {
|
||||||
"windowSize": {
|
"windowSize": {
|
||||||
"width": 660,
|
"width": 660,
|
||||||
|
|||||||
@@ -1,3 +1,27 @@
|
|||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[tauri::command]
|
||||||
|
fn updater_smoke_menu(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
let submenu = app.menu().unwrap().get("app-menu").unwrap();
|
||||||
|
let item = submenu.as_submenu().unwrap().get("install-update").unwrap();
|
||||||
|
let item = item.as_menuitem().unwrap();
|
||||||
|
for (available, busy, expected) in [
|
||||||
|
(false, false, false), // No update.
|
||||||
|
(true, false, true), // Available update, also after a retryable failure.
|
||||||
|
(true, true, false), // Download or installation in progress.
|
||||||
|
(true, false, true), // Retry after a failed download.
|
||||||
|
(false, true, false), // A new check clears the previous update.
|
||||||
|
(false, false, false), // Up to date.
|
||||||
|
] {
|
||||||
|
kanbangit_native::refresh_update_menu_for_smoke_test(&app, available, busy);
|
||||||
|
if item.is_enabled().map_err(|error| error.to_string())? != expected {
|
||||||
|
return Err(format!(
|
||||||
|
"Native install menu has wrong state: available={available}, busy={busy}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn updater_smoke_report(app: tauri::AppHandle, passed: bool, reason: String) {
|
fn updater_smoke_report(app: tauri::AppHandle, passed: bool, reason: String) {
|
||||||
@@ -36,6 +60,7 @@ fn main() {
|
|||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
for (let i = 0; i < 100 && !$('installed').textContent; i++) await new Promise(r => setTimeout(r, 50));
|
for (let i = 0; i < 100 && !$('installed').textContent; i++) await new Promise(r => setTimeout(r, 50));
|
||||||
if ($('installed').textContent !== window.expectedInstalled) throw new Error('Actual installed version/build failed: ' + $('installed').textContent);
|
if ($('installed').textContent !== window.expectedInstalled) throw new Error('Actual installed version/build failed: ' + $('installed').textContent);
|
||||||
|
await window.__TAURI_INTERNALS__.invoke('updater_smoke_menu');
|
||||||
const state = { phase: 'current', currentVersion: '0.1.2', build: '2', version: null, notes: null, downloaded: 0, total: null, error: null, preferences: { automatic: true, intervalHours: 6 } };
|
const state = { phase: 'current', currentVersion: '0.1.2', build: '2', version: null, notes: null, downloaded: 0, total: null, error: null, preferences: { automatic: true, intervalHours: 6 } };
|
||||||
window.updateStatus(state);
|
window.updateStatus(state);
|
||||||
if (!$('installed').textContent.includes('0.1.2 (build 2)') || !$('install').hidden || $('check').disabled) throw new Error('Current version state failed');
|
if (!$('installed').textContent.includes('0.1.2 (build 2)') || !$('install').hidden || $('check').disabled) throw new Error('Current version state failed');
|
||||||
@@ -64,7 +89,10 @@ fn main() {
|
|||||||
context.config_mut().identifier = format!("de.kanbangit.smoke.updates.{}", std::process::id());
|
context.config_mut().identifier = format!("de.kanbangit.smoke.updates.{}", std::process::id());
|
||||||
let app = kanbangit_native::builder()
|
let app = kanbangit_native::builder()
|
||||||
.manage(passed.clone())
|
.manage(passed.clone())
|
||||||
.invoke_handler(tauri::generate_handler![updater_smoke_report])
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
updater_smoke_report,
|
||||||
|
updater_smoke_menu
|
||||||
|
])
|
||||||
.plugin(plugin)
|
.plugin(plugin)
|
||||||
.build(context)
|
.build(context)
|
||||||
.expect("App must start");
|
.expect("App must start");
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function fixture(t) {
|
|||||||
);
|
);
|
||||||
return { ...result, output: readFileSync(output, 'utf8') };
|
return { ...result, output: readFileSync(output, 'utf8') };
|
||||||
};
|
};
|
||||||
return { run, git, checkout, commit };
|
return { run, git, checkout, remote, commit };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('release selection succeeds using Git alone without an administrative API token', (t) => {
|
test('release selection succeeds using Git alone without an administrative API token', (t) => {
|
||||||
@@ -111,38 +111,39 @@ test('manual release on main accepts an existing tag without moving it', (t) =>
|
|||||||
});
|
});
|
||||||
assert.equal(result.status, 0, result.stderr);
|
assert.equal(result.status, 0, result.stderr);
|
||||||
assert.equal(f.git(f.checkout, 'rev-parse', 'macos-v1.2.3'), f.commit);
|
assert.equal(f.git(f.checkout, 'rev-parse', 'macos-v1.2.3'), f.commit);
|
||||||
|
assert.doesNotMatch(workflow, /inputs\.mode|RELEASE_MODE|\n {2}resume:/);
|
||||||
|
assert.match(workflow, /--ci --target .* --bundles app,dmg/);
|
||||||
|
assert.match(workflow, /node native\/macos-ci.mjs package/);
|
||||||
|
assert.match(workflow, /node native\/publish-macos.mjs upload/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resume uses current tools, validated tagged metadata and release credentials without rebuilding', (t) => {
|
test('a moved macOS tag selects the new main commit for a full rebuild', (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
const result = f.run({
|
f.git(
|
||||||
GITHUB_EVENT_NAME: 'workflow_dispatch',
|
f.remote,
|
||||||
GITHUB_REF: 'refs/heads/main',
|
'-c',
|
||||||
REQUESTED_TAG: 'macos-v1.2.3',
|
'commit.gpgsign=false',
|
||||||
RELEASE_MODE: 'resume',
|
'commit',
|
||||||
});
|
'--allow-empty',
|
||||||
|
'-m',
|
||||||
|
'Fix failed release',
|
||||||
|
);
|
||||||
|
const rebuilt = f.git(f.remote, 'rev-parse', 'HEAD');
|
||||||
|
f.git(f.remote, 'tag', '--force', 'macos-v1.2.3');
|
||||||
|
f.git(
|
||||||
|
f.checkout,
|
||||||
|
'fetch',
|
||||||
|
'--force',
|
||||||
|
'origin',
|
||||||
|
'refs/tags/macos-v1.2.3:refs/tags/macos-v1.2.3',
|
||||||
|
);
|
||||||
|
const result = f.run();
|
||||||
assert.equal(result.status, 0, result.stderr);
|
assert.equal(result.status, 0, result.stderr);
|
||||||
const resume = workflow.split('\n resume:\n')[1];
|
assert.equal(result.output, `commit=${rebuilt}\ntag=macos-v1.2.3\n`);
|
||||||
assert.match(resume, /needs: prepare/);
|
assert.notEqual(rebuilt, f.commit);
|
||||||
assert.match(
|
assert.match(workflow, /tags:\n\s+- "macos-v\*"/);
|
||||||
resume,
|
assert.match(workflow, /^ {2}group: macos-release$/m);
|
||||||
/github.event_name == 'workflow_dispatch' && inputs.mode == 'resume'/,
|
assert.match(workflow, /^ {2}cancel-in-progress: false$/m);
|
||||||
);
|
|
||||||
assert.match(resume, /ref: \$\{\{ github.sha \}\}/);
|
|
||||||
assert.match(resume, /ref: \$\{\{ needs.prepare.outputs.commit \}\}/);
|
|
||||||
assert.match(resume, /RELEASE_METADATA_DIRECTORY: release-source\/native/);
|
|
||||||
assert.match(resume, /path: release-source/);
|
|
||||||
assert.match(resume, /secrets.S3_ACCESS_KEY_ID/);
|
|
||||||
assert.match(resume, /secrets.S3_SECRET_ACCESS_KEY/);
|
|
||||||
assert.match(resume, /node native\/publish-macos.mjs resume/);
|
|
||||||
assert.doesNotMatch(
|
|
||||||
resume,
|
|
||||||
/tauri-action|APPLE_PASSWORD|APPLE_CERTIFICATE|TAURI_SIGNING_PRIVATE_KEY/,
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
workflow.split('\n release:\n')[1],
|
|
||||||
/if: \$\{\{ inputs.mode != 'resume' \}\}/,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('release DMG packaging disables Finder automation in the Tauri action environment', () => {
|
test('release DMG packaging disables Finder automation in the Tauri action environment', () => {
|
||||||
@@ -159,7 +160,7 @@ test('release DMG packaging disables Finder automation in the Tauri action envir
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const [name, overrides] of [
|
for (const [name, overrides] of [
|
||||||
['invalid release mode', { RELEASE_MODE: 'overwrite' }],
|
['wrong tag case', { GITHUB_REF: 'refs/tags/macOS-V1.2.3' }],
|
||||||
['web release tag', { GITHUB_REF: 'refs/tags/v1.2.3' }],
|
['web release tag', { GITHUB_REF: 'refs/tags/v1.2.3' }],
|
||||||
['missing tag', { GITHUB_REF: 'refs/tags/macos-v9.9.9' }],
|
['missing tag', { GITHUB_REF: 'refs/tags/macos-v9.9.9' }],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,18 +1,37 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { Buffer } from 'node:buffer';
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import {
|
import {
|
||||||
conditionalEtag,
|
conditionalEtag,
|
||||||
publishMacos,
|
publishMacos,
|
||||||
resumeMacos,
|
|
||||||
storageConfiguration,
|
storageConfiguration,
|
||||||
} from '../publish-macos.mjs';
|
} from '../publish-macos.mjs';
|
||||||
import { signedFixture } from './updater-fixture.mjs';
|
import { signedFixture } from './updater-fixture.mjs';
|
||||||
|
|
||||||
|
test('publisher rejects the removed resume command before accessing storage', () => {
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
fileURLToPath(new URL('../publish-macos.mjs', import.meta.url)),
|
||||||
|
'resume',
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.equal(result.stdout, '');
|
||||||
|
assert.match(
|
||||||
|
result.stderr,
|
||||||
|
/Use config or upload; upload requires RELEASE_DIRECTORY/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('conditional writes use bare S3 ETags without weakening the condition', () => {
|
test('conditional writes use bare S3 ETags without weakening the condition', () => {
|
||||||
const etag = 'b88312b96169f4b2098a4cc43b0abb6a';
|
const etag = 'b88312b96169f4b2098a4cc43b0abb6a';
|
||||||
for (const value of [etag, `"${etag}"`, `${etag}-2`, `"${etag}-2"`]) {
|
for (const value of [etag, `"${etag}"`, `${etag}-2`, `"${etag}-2"`]) {
|
||||||
@@ -36,37 +55,6 @@ test('conditional writes use bare S3 ETags without weakening the condition', ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resume verifies already published artifacts and promotes the feed without rebuilding', async (t) => {
|
|
||||||
const f = fixture(t);
|
|
||||||
await publishMacos(f);
|
|
||||||
const key = `releases/macos/${f.metadata.target}/latest.json`;
|
|
||||||
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
|
||||||
const originals = new Map(f.objects);
|
|
||||||
await resumeMacos(f);
|
|
||||||
assert.equal(JSON.parse(f.objects.get(key)).version, f.metadata.version);
|
|
||||||
for (const [name, contents] of originals) {
|
|
||||||
if (name !== key) {
|
|
||||||
assert.deepEqual(f.objects.get(name), contents);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const failure of ['missing', 'tampered']) {
|
|
||||||
test(`resume rejects ${failure} artifacts before any S3 writes`, async (t) => {
|
|
||||||
const f = fixture(t);
|
|
||||||
await publishMacos(f);
|
|
||||||
const key = `releases/macos/${f.metadata.target}/${f.metadata.version}/${f.metadata.updaterFilename}`;
|
|
||||||
if (failure === 'missing') {
|
|
||||||
f.objects.delete(key);
|
|
||||||
} else {
|
|
||||||
f.objects.set(key, Buffer.from('tampered'));
|
|
||||||
}
|
|
||||||
f.events.length = 0;
|
|
||||||
await assert.rejects(resumeMacos(f), /incomplete release|signature/);
|
|
||||||
assert(!f.events.some((event) => event.operation === 'put-object'));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fixture(
|
function fixture(
|
||||||
t,
|
t,
|
||||||
conflictMessage = 'aws failed: (PreconditionFailed) object exists',
|
conflictMessage = 'aws failed: (PreconditionFailed) object exists',
|
||||||
@@ -111,7 +99,7 @@ function fixture(
|
|||||||
const operation = args[args.indexOf('s3api') + 1];
|
const operation = args[args.indexOf('s3api') + 1];
|
||||||
const value = (name) => args[args.indexOf(name) + 1];
|
const value = (name) => args[args.indexOf(name) + 1];
|
||||||
const key = value('--key');
|
const key = value('--key');
|
||||||
events.push({ operation, key });
|
events.push({ operation, key, args });
|
||||||
if (operation === 'get-bucket-versioning') {
|
if (operation === 'get-bucket-versioning') {
|
||||||
assert.equal(value('--output'), 'json');
|
assert.equal(value('--output'), 'json');
|
||||||
return '';
|
return '';
|
||||||
@@ -133,6 +121,9 @@ function fixture(
|
|||||||
return '{}';
|
return '{}';
|
||||||
}
|
}
|
||||||
if (operation === 'get-object') {
|
if (operation === 'get-object') {
|
||||||
|
if (!objects.has(key)) {
|
||||||
|
throw new Error('aws failed: (NoSuchKey) object missing');
|
||||||
|
}
|
||||||
writeFileSync(args.at(-1), objects.get(key));
|
writeFileSync(args.at(-1), objects.get(key));
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
ETag: '"b88312b96169f4b2098a4cc43b0abb6a"',
|
ETag: '"b88312b96169f4b2098a4cc43b0abb6a"',
|
||||||
@@ -168,7 +159,7 @@ function fixture(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('uploads immutable files and writes completion record after public verification', async (t) => {
|
test('uploads revalidated files and writes completion record after public verification', async (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
const url = await publishMacos(f);
|
const url = await publishMacos(f);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -177,14 +168,42 @@ test('uploads immutable files and writes completion record after public verifica
|
|||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.events.filter((event) => event.operation === 'put-object').length,
|
f.events.filter((event) => event.operation === 'put-object').length,
|
||||||
8,
|
9,
|
||||||
);
|
);
|
||||||
assert.equal(f.objects.size, 8);
|
assert.equal(f.objects.size, 9);
|
||||||
|
for (const event of f.events.filter(
|
||||||
|
(event) => event.operation === 'put-object',
|
||||||
|
)) {
|
||||||
|
assert.equal(
|
||||||
|
event.args[event.args.indexOf('--cache-control') + 1],
|
||||||
|
'no-cache, max-age=0, must-revalidate',
|
||||||
|
);
|
||||||
|
}
|
||||||
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.events.at(-1).key,
|
f.events.at(-1).key,
|
||||||
'releases/macos/aarch64-apple-darwin/latest.json',
|
'releases/macos/aarch64-apple-darwin/latest.json',
|
||||||
);
|
);
|
||||||
|
const downloadKey = 'releases/macos/aarch64-apple-darwin/KanbanGit.dmg';
|
||||||
|
assert.deepEqual(
|
||||||
|
f.objects.get(downloadKey),
|
||||||
|
readFileSync(join(f.directory, f.metadata.filename)),
|
||||||
|
);
|
||||||
|
const downloadPut = f.events.findIndex(
|
||||||
|
(event) =>
|
||||||
|
event.operation === 'put-object' && event.key === downloadKey,
|
||||||
|
);
|
||||||
|
const downloadVerified = f.events.findIndex(
|
||||||
|
(event) =>
|
||||||
|
event.operation === 'public-get' && event.key === downloadKey,
|
||||||
|
);
|
||||||
|
const feedPut = f.events.findIndex(
|
||||||
|
(event) =>
|
||||||
|
event.operation === 'put-object' &&
|
||||||
|
event.key.endsWith('/latest.json'),
|
||||||
|
);
|
||||||
|
assert(downloadPut > 0 && downloadVerified > downloadPut);
|
||||||
|
assert(feedPut > downloadVerified);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
JSON.parse(
|
JSON.parse(
|
||||||
f.objects.get(
|
f.objects.get(
|
||||||
@@ -203,6 +222,91 @@ test('uploads immutable files and writes completion record after public verifica
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('replaces the stable download only for a newer verified release', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
||||||
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||||
|
f.objects.set(downloadKey, Buffer.from('previous download'));
|
||||||
|
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||||
|
|
||||||
|
await publishMacos(f);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
f.objects.get(downloadKey),
|
||||||
|
readFileSync(join(f.directory, f.metadata.filename)),
|
||||||
|
);
|
||||||
|
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
|
||||||
|
const replacement = f.events.find(
|
||||||
|
(event) =>
|
||||||
|
event.operation === 'put-object' &&
|
||||||
|
event.key === downloadKey &&
|
||||||
|
event.args.includes('--if-match'),
|
||||||
|
);
|
||||||
|
assert(replacement);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a newer published version protects the stable download even when its alias is missing', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
||||||
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||||
|
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '2.0.0' })));
|
||||||
|
|
||||||
|
await assert.rejects(publishMacos(f), /downgrade/);
|
||||||
|
|
||||||
|
assert.equal(f.objects.has(downloadKey), false);
|
||||||
|
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '2.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a published version cannot replace a different stable download', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
||||||
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||||
|
const original = Buffer.from('published download');
|
||||||
|
f.objects.set(downloadKey, original);
|
||||||
|
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.2.3' })));
|
||||||
|
|
||||||
|
await assert.rejects(publishMacos(f), /bump the app version/);
|
||||||
|
|
||||||
|
assert.deepEqual(f.objects.get(downloadKey), original);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed public stable download blocks feed promotion', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const fetchPublic = f.fetchPublic;
|
||||||
|
f.fetchPublic = (url, options) =>
|
||||||
|
url.endsWith('/KanbanGit.dmg')
|
||||||
|
? Promise.resolve({ ok: false, status: 403 })
|
||||||
|
: fetchPublic(url, options);
|
||||||
|
|
||||||
|
await assert.rejects(publishMacos(f), /KanbanGit\.dmg: HTTP 403/);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
f.objects.has(`releases/macos/${f.metadata.target}/latest.json`),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent stable download replacement preserves the winner', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
||||||
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||||
|
f.objects.set(downloadKey, Buffer.from('previous download'));
|
||||||
|
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||||
|
const run = f.run;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
if (args.includes('--if-match') && args.includes(downloadKey)) {
|
||||||
|
f.objects.set(downloadKey, Buffer.from('concurrent winner'));
|
||||||
|
throw new Error('(PreconditionFailed) concurrent replacement');
|
||||||
|
}
|
||||||
|
return run(command, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(publishMacos(f), /concurrent replacement/);
|
||||||
|
|
||||||
|
assert.equal(f.objects.get(downloadKey).toString(), 'concurrent winner');
|
||||||
|
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
for (const response of ['', ' \n\t', '{}']) {
|
for (const response of ['', ' \n\t', '{}']) {
|
||||||
test(`unversioned bucket response ${JSON.stringify(response)} permits a verified upload`, async (t) => {
|
test(`unversioned bucket response ${JSON.stringify(response)} permits a verified upload`, async (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
@@ -212,7 +316,7 @@ for (const response of ['', ' \n\t', '{}']) {
|
|||||||
return args.includes('get-bucket-versioning') ? response : result;
|
return args.includes('get-bucket-versioning') ? response : result;
|
||||||
};
|
};
|
||||||
await publishMacos(f);
|
await publishMacos(f);
|
||||||
assert.equal(f.objects.size, 8);
|
assert.equal(f.objects.size, 9);
|
||||||
assert.equal(f.events.at(-1).operation, 'public-get');
|
assert.equal(f.events.at(-1).operation, 'public-get');
|
||||||
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
||||||
});
|
});
|
||||||
@@ -238,18 +342,24 @@ test('retry accepts byte-identical objects without replacing them', async (t) =>
|
|||||||
assert.deepEqual([...f.objects.entries()], originals);
|
assert.deepEqual([...f.objects.entries()], originals);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.events.filter((event) => event.operation === 'get-object').length,
|
f.events.filter((event) => event.operation === 'get-object').length,
|
||||||
8,
|
11,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('different existing release is never overwritten', async (t) => {
|
for (const published of ['1.2.3', '1.2.4']) {
|
||||||
const f = fixture(t);
|
test(`published release stays protected when the feed is at ${published}`, async (t) => {
|
||||||
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
const f = fixture(t);
|
||||||
f.objects.set(key, Buffer.from('previous version content'));
|
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||||
await assert.rejects(publishMacos(f), /bump the app version/);
|
f.objects.set(key, Buffer.from('previous version content'));
|
||||||
assert.equal(f.objects.get(key).toString(), 'previous version content');
|
f.objects.set(
|
||||||
assert.equal(f.objects.size, 1);
|
`releases/macos/${f.metadata.target}/latest.json`,
|
||||||
});
|
Buffer.from(JSON.stringify({ version: published })),
|
||||||
|
);
|
||||||
|
await assert.rejects(publishMacos(f), /bump the app version/);
|
||||||
|
assert.equal(f.objects.get(key).toString(), 'previous version content');
|
||||||
|
assert.equal(f.objects.size, 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('checksum mismatch aborts before contacting S3', async (t) => {
|
test('checksum mismatch aborts before contacting S3', async (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
@@ -304,7 +414,7 @@ for (const failure of ['private', 'wrong-content', 'network']) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('a partial upload can resume with the same files', async (t) => {
|
test('a partial upload can be retried with the same files', async (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
const run = f.run;
|
const run = f.run;
|
||||||
f.run = (command, args) => {
|
f.run = (command, args) => {
|
||||||
@@ -320,9 +430,206 @@ test('a partial upload can resume with the same files', async (t) => {
|
|||||||
assert.equal(f.objects.size, 3);
|
assert.equal(f.objects.size, 3);
|
||||||
f.run = run;
|
f.run = run;
|
||||||
await publishMacos(f);
|
await publishMacos(f);
|
||||||
assert.equal(f.objects.size, 8);
|
assert.equal(f.objects.size, 9);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function rebuild(f) {
|
||||||
|
const updater = Buffer.from('rebuilt signed update');
|
||||||
|
const signing = signedFixture(updater);
|
||||||
|
f.publicKey = signing.publicKey;
|
||||||
|
f.metadata.notes = 'Fixed build of the same version';
|
||||||
|
for (const [name, contents] of [
|
||||||
|
[f.metadata.filename, Buffer.from('rebuilt disk image')],
|
||||||
|
[f.metadata.updaterFilename, updater],
|
||||||
|
]) {
|
||||||
|
writeFileSync(join(f.directory, name), contents);
|
||||||
|
writeFileSync(
|
||||||
|
join(f.directory, `${name}.sha256`),
|
||||||
|
`${createHash('sha256').update(contents).digest('hex')} ${name}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
writeFileSync(
|
||||||
|
join(f.directory, `${f.metadata.updaterFilename}.sig`),
|
||||||
|
signing.signature,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const previous of [null, '1.0.0']) {
|
||||||
|
for (const interruptedFile of ['.sha256', '/latest.json']) {
|
||||||
|
test(`rebuild replaces unfinished ${interruptedFile} release with previous feed ${previous}`, async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||||
|
const previousBytes =
|
||||||
|
previous === null
|
||||||
|
? undefined
|
||||||
|
: Buffer.from(JSON.stringify({ version: previous }));
|
||||||
|
if (previousBytes) {
|
||||||
|
f.objects.set(latestKey, previousBytes);
|
||||||
|
}
|
||||||
|
const run = f.run;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
if (
|
||||||
|
args.includes('put-object') &&
|
||||||
|
args[args.indexOf('--key') + 1].endsWith(interruptedFile)
|
||||||
|
) {
|
||||||
|
throw new Error('upload interrupted');
|
||||||
|
}
|
||||||
|
return run(command, args);
|
||||||
|
};
|
||||||
|
await assert.rejects(publishMacos(f), /upload interrupted/);
|
||||||
|
assert.deepEqual(f.objects.get(latestKey), previousBytes);
|
||||||
|
rebuild(f);
|
||||||
|
f.events.length = 0;
|
||||||
|
let promoted = false;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
if (!promoted) {
|
||||||
|
assert.deepEqual(f.objects.get(latestKey), previousBytes);
|
||||||
|
}
|
||||||
|
const result = run(command, args);
|
||||||
|
if (args.includes('put-object') && args.includes(latestKey)) {
|
||||||
|
promoted = true;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
await publishMacos(f);
|
||||||
|
assert.equal(f.objects.size, 9);
|
||||||
|
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
|
||||||
|
assert.equal(
|
||||||
|
JSON.parse(f.objects.get(latestKey)).notes,
|
||||||
|
f.metadata.notes,
|
||||||
|
);
|
||||||
|
for (const name of [
|
||||||
|
f.metadata.filename,
|
||||||
|
`${f.metadata.filename}.sha256`,
|
||||||
|
f.metadata.updaterFilename,
|
||||||
|
`${f.metadata.updaterFilename}.sig`,
|
||||||
|
`${f.metadata.updaterFilename}.sha256`,
|
||||||
|
]) {
|
||||||
|
assert.deepEqual(
|
||||||
|
f.objects.get(
|
||||||
|
`releases/macos/${f.metadata.target}/1.2.3/${name}`,
|
||||||
|
),
|
||||||
|
readFileSync(join(f.directory, name)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const published = [...f.objects.entries()];
|
||||||
|
rebuild(f);
|
||||||
|
await assert.rejects(publishMacos(f), /already been released/);
|
||||||
|
assert.deepEqual([...f.objects.entries()], published);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('failure after promoting the feed still protects that version from rebuilding', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const fetchPublic = f.fetchPublic;
|
||||||
|
f.fetchPublic = async (url, options) =>
|
||||||
|
url.endsWith('/latest.json')
|
||||||
|
? { ok: false, status: 403 }
|
||||||
|
: fetchPublic(url, options);
|
||||||
|
await assert.rejects(publishMacos(f), /HTTP 403/);
|
||||||
|
const published = [...f.objects.entries()];
|
||||||
|
rebuild(f);
|
||||||
|
await assert.rejects(publishMacos(f), /already been released/);
|
||||||
|
assert.deepEqual([...f.objects.entries()], published);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a concurrent change to a partial artifact is not overwritten', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||||
|
f.objects.set(key, Buffer.from('unfinished old build'));
|
||||||
|
const run = f.run;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
if (args.includes('--if-match')) {
|
||||||
|
assert(args.includes(key));
|
||||||
|
f.objects.set(key, Buffer.from('concurrent winner'));
|
||||||
|
throw new Error('(PreconditionFailed) concurrent replacement');
|
||||||
|
}
|
||||||
|
return run(command, args);
|
||||||
|
};
|
||||||
|
await assert.rejects(publishMacos(f), /concurrent replacement/);
|
||||||
|
assert.equal(f.objects.get(key).toString(), 'concurrent winner');
|
||||||
|
assert.equal(f.objects.size, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const failure of [
|
||||||
|
'AccessDenied',
|
||||||
|
'network failure',
|
||||||
|
'invalid JSON',
|
||||||
|
'invalid version',
|
||||||
|
]) {
|
||||||
|
test(`cannot replace a partial artifact when the feed check has ${failure}`, async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||||
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||||
|
f.objects.set(key, Buffer.from('unfinished old build'));
|
||||||
|
f.objects.set(
|
||||||
|
latestKey,
|
||||||
|
Buffer.from(
|
||||||
|
failure === 'invalid version' ? '{"version":"invalid"}' : '{',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const originals = [...f.objects.entries()];
|
||||||
|
const run = f.run;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
if (
|
||||||
|
args.includes('get-object') &&
|
||||||
|
args.includes(latestKey) &&
|
||||||
|
!failure.startsWith('invalid')
|
||||||
|
) {
|
||||||
|
throw new Error(failure);
|
||||||
|
}
|
||||||
|
return run(command, args);
|
||||||
|
};
|
||||||
|
await assert.rejects(publishMacos(f));
|
||||||
|
assert.deepEqual([...f.objects.entries()], originals);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const listing of [
|
||||||
|
{ KeyCount: 0, IsTruncated: false },
|
||||||
|
{ KeyCount: 1, IsTruncated: false, Contents: [{ Key: 'latest.json' }] },
|
||||||
|
{},
|
||||||
|
{ KeyCount: 0, IsTruncated: true },
|
||||||
|
{ KeyCount: 0, IsTruncated: false, Contents: '' },
|
||||||
|
'AccessDenied',
|
||||||
|
]) {
|
||||||
|
test(`ambiguous feed read error needs a proven empty listing: ${JSON.stringify(listing)}`, async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||||
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||||
|
f.objects.set(key, Buffer.from('unfinished old build'));
|
||||||
|
const run = f.run;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
if (args.includes('get-object') && args.includes(latestKey)) {
|
||||||
|
throw new Error(
|
||||||
|
"argument of type 'NoneType' is not a container or iterable",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (args.includes('list-objects-v2')) {
|
||||||
|
assert.equal(args[args.indexOf('--prefix') + 1], latestKey);
|
||||||
|
if (listing === 'AccessDenied') {
|
||||||
|
throw new Error('AccessDenied');
|
||||||
|
}
|
||||||
|
return JSON.stringify(listing);
|
||||||
|
}
|
||||||
|
return run(command, args);
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
listing.KeyCount === 0 &&
|
||||||
|
listing.IsTruncated === false &&
|
||||||
|
!('Contents' in listing)
|
||||||
|
) {
|
||||||
|
await publishMacos(f);
|
||||||
|
assert.equal(f.objects.size, 9);
|
||||||
|
} else {
|
||||||
|
await assert.rejects(publishMacos(f), /NoneType|AccessDenied/);
|
||||||
|
assert.equal(f.objects.get(key).toString(), 'unfinished old build');
|
||||||
|
assert.equal(f.objects.size, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
|
for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
|
||||||
test(`temporary public download failure ${status} recovers before completion`, async (t) => {
|
test(`temporary public download failure ${status} recovers before completion`, async (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
@@ -347,7 +654,7 @@ for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
|
|||||||
await publishMacos(f);
|
await publishMacos(f);
|
||||||
assert.deepEqual(waits, [2000, 2000]);
|
assert.deepEqual(waits, [2000, 2000]);
|
||||||
assert.equal(cancelled, status === 'network' ? 0 : 2);
|
assert.equal(cancelled, status === 'network' ? 0 : 2);
|
||||||
assert.equal(f.objects.size, 8);
|
assert.equal(f.objects.size, 9);
|
||||||
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -391,7 +698,7 @@ test('checksum access retries finish before publishing the completion record', a
|
|||||||
};
|
};
|
||||||
await publishMacos(f);
|
await publishMacos(f);
|
||||||
assert.equal(failures, 3);
|
assert.equal(failures, 3);
|
||||||
assert.equal(f.objects.size, 8);
|
assert.equal(f.objects.size, 9);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const overrides of [
|
for (const overrides of [
|
||||||
@@ -443,13 +750,13 @@ test('rejects a corrupt updater archive before publishing any object', async (t)
|
|||||||
assert.equal(f.objects.size, 0);
|
assert.equal(f.objects.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('atomically promotes a newer feed only after all immutable objects are public', async (t) => {
|
test('atomically promotes a newer feed only after all release files are public', async (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||||
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||||
await publishMacos(f);
|
await publishMacos(f);
|
||||||
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
|
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
|
||||||
const immutableVerified = f.events.findIndex(
|
const releaseVerified = f.events.findIndex(
|
||||||
(event) =>
|
(event) =>
|
||||||
event.operation === 'public-get' &&
|
event.operation === 'public-get' &&
|
||||||
event.key.endsWith('/update.json'),
|
event.key.endsWith('/update.json'),
|
||||||
@@ -457,7 +764,7 @@ test('atomically promotes a newer feed only after all immutable objects are publ
|
|||||||
const latestPut = f.events.findIndex(
|
const latestPut = f.events.findIndex(
|
||||||
(event) => event.operation === 'put-object' && event.key === latestKey,
|
(event) => event.operation === 'put-object' && event.key === latestKey,
|
||||||
);
|
);
|
||||||
assert(latestPut > immutableVerified);
|
assert(latestPut > releaseVerified);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const previous of [
|
for (const previous of [
|
||||||
@@ -508,12 +815,16 @@ for (const message of [
|
|||||||
assert.deepEqual([...f.objects.entries()], published);
|
assert.deepEqual([...f.objects.entries()], published);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`empty S3 error cannot bypass immutable content checks: ${message}`, async (t) => {
|
test(`empty S3 error cannot replace a published release: ${message}`, async (t) => {
|
||||||
const f = fixture(t, message);
|
const f = fixture(t, message);
|
||||||
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||||
f.objects.set(key, Buffer.from('different existing release'));
|
f.objects.set(key, Buffer.from('different existing release'));
|
||||||
|
f.objects.set(
|
||||||
|
`releases/macos/${f.metadata.target}/latest.json`,
|
||||||
|
Buffer.from(JSON.stringify({ version: '1.2.3' })),
|
||||||
|
);
|
||||||
await assert.rejects(publishMacos(f), /bump the app version/);
|
await assert.rejects(publishMacos(f), /bump the app version/);
|
||||||
assert.equal(f.objects.size, 1);
|
assert.equal(f.objects.size, 2);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.objects.get(key).toString(),
|
f.objects.get(key).toString(),
|
||||||
'different existing release',
|
'different existing release',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
import { Link, usePage } from '@inertiajs/vue3';
|
||||||
import { Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
|
import { Download, Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
||||||
import UserMenuContent from '@/components/UserMenuContent.vue';
|
import UserMenuContent from '@/components/UserMenuContent.vue';
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from '@/composables/useAppearance';
|
} from '@/composables/useAppearance';
|
||||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { useMacDownload } from '@/composables/useMacDownload';
|
||||||
import { create as createProject } from '@/routes/projects';
|
import { create as createProject } from '@/routes/projects';
|
||||||
import { index as board } from '@/routes/projects/board';
|
import { index as board } from '@/routes/projects/board';
|
||||||
import { create as createTicket } from '@/routes/projects/tickets';
|
import { create as createTicket } from '@/routes/projects/tickets';
|
||||||
@@ -35,6 +36,7 @@ const page = usePage();
|
|||||||
const { project } = useAppNavigation();
|
const { project } = useAppNavigation();
|
||||||
const { isCurrentUrl } = useCurrentUrl();
|
const { isCurrentUrl } = useCurrentUrl();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
const { macDownloadUrl, showMacDownload } = useMacDownload();
|
||||||
const { appearance, updateAppearance } = useAppearance();
|
const { appearance, updateAppearance } = useAppearance();
|
||||||
const themeIcon = computed(
|
const themeIcon = computed(
|
||||||
() => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value],
|
() => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value],
|
||||||
@@ -69,6 +71,21 @@ const createAction = computed(() => {
|
|||||||
<span v-else class="truncate font-semibold">KanbanGit</span>
|
<span v-else class="truncate font-semibold">KanbanGit</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 items-center gap-1 sm:gap-2">
|
<div class="flex shrink-0 items-center gap-1 sm:gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="showMacDownload"
|
||||||
|
as-child
|
||||||
|
variant="outline"
|
||||||
|
:aria-label="t('navigation.downloadAppAppleSilicon')"
|
||||||
|
:title="t('navigation.downloadAppAppleSilicon')"
|
||||||
|
class="px-3"
|
||||||
|
>
|
||||||
|
<a :href="macDownloadUrl"
|
||||||
|
><Download aria-hidden="true" /><span
|
||||||
|
class="hidden sm:inline"
|
||||||
|
>{{ t('navigation.downloadApp') }}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="createAction"
|
v-if="createAction"
|
||||||
as-child
|
as-child
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import updateSource from '../../../native/update-source.json';
|
||||||
|
|
||||||
|
export const macDownloadUrl = `${updateSource.baseUrl}/aarch64-apple-darwin/KanbanGit.dmg`;
|
||||||
|
|
||||||
|
export function isMacDownloadAvailable(
|
||||||
|
userAgent: string,
|
||||||
|
maxTouchPoints: number,
|
||||||
|
isTauri: unknown,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
userAgent.includes('Macintosh') &&
|
||||||
|
maxTouchPoints < 2 &&
|
||||||
|
isTauri !== true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMacDownload() {
|
||||||
|
const showMacDownload = ref(false);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
showMacDownload.value = isMacDownloadAvailable(
|
||||||
|
navigator.userAgent,
|
||||||
|
navigator.maxTouchPoints,
|
||||||
|
Reflect.get(window, 'isTauri'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { macDownloadUrl, showMacDownload };
|
||||||
|
}
|
||||||
@@ -1,20 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
import { ShieldCheck } from 'lucide-vue-next';
|
import { Download, ShieldCheck } from 'lucide-vue-next';
|
||||||
import type { Component } from 'vue';
|
import type { Component } from 'vue';
|
||||||
import AppLogo from '@/components/AppLogo.vue';
|
import AppLogo from '@/components/AppLogo.vue';
|
||||||
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { useMacDownload } from '@/composables/useMacDownload';
|
||||||
import { home } from '@/routes/index';
|
import { home } from '@/routes/index';
|
||||||
|
|
||||||
defineProps<{ title: string; description: string; icon?: Component }>();
|
defineProps<{ title: string; description: string; icon?: Component }>();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
const { macDownloadUrl, showMacDownload } = useMacDownload();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="bg-background text-foreground flex min-h-dvh flex-col">
|
<div class="bg-background text-foreground flex min-h-dvh flex-col">
|
||||||
<header
|
<header
|
||||||
class="bg-card px-page flex justify-center border-b py-4 sm:justify-start"
|
class="bg-card px-page flex items-center justify-between gap-3 border-b py-4"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
:href="home()"
|
:href="home()"
|
||||||
@@ -22,6 +25,21 @@ const { t } = useLocalization();
|
|||||||
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
||||||
><AppLogo
|
><AppLogo
|
||||||
/></Link>
|
/></Link>
|
||||||
|
<Button
|
||||||
|
v-if="showMacDownload"
|
||||||
|
as-child
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:aria-label="t('navigation.downloadAppAppleSilicon')"
|
||||||
|
:title="t('navigation.downloadAppAppleSilicon')"
|
||||||
|
>
|
||||||
|
<a :href="macDownloadUrl"
|
||||||
|
><Download aria-hidden="true" /><span
|
||||||
|
class="hidden sm:inline"
|
||||||
|
>{{ t('navigation.downloadApp') }}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
<main
|
<main
|
||||||
class="gap-column sm:px-page mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-8 sm:py-12"
|
class="gap-column sm:px-page mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-8 sm:py-12"
|
||||||
|
|||||||
@@ -718,6 +718,7 @@ export const en = {
|
|||||||
'welcome.action.dashboard': 'Open dashboard',
|
'welcome.action.dashboard': 'Open dashboard',
|
||||||
'welcome.action.dashboardShort': 'Dashboard',
|
'welcome.action.dashboardShort': 'Dashboard',
|
||||||
'welcome.actionHint': 'One workspace for boards, tickets, and Git context.',
|
'welcome.actionHint': 'One workspace for boards, tickets, and Git context.',
|
||||||
|
'welcome.downloadAppHint': 'For Macs with Apple Silicon.',
|
||||||
'welcome.preview.label': 'KanbanGit board preview',
|
'welcome.preview.label': 'KanbanGit board preview',
|
||||||
'welcome.preview.project': 'Product delivery board',
|
'welcome.preview.project': 'Product delivery board',
|
||||||
'welcome.preview.connected': 'Git connected',
|
'welcome.preview.connected': 'Git connected',
|
||||||
@@ -899,6 +900,9 @@ export const en = {
|
|||||||
'settings.eyebrow': 'Account settings',
|
'settings.eyebrow': 'Account settings',
|
||||||
'navigation.createTicket': 'New ticket',
|
'navigation.createTicket': 'New ticket',
|
||||||
'navigation.createProject': 'New project',
|
'navigation.createProject': 'New project',
|
||||||
|
'navigation.downloadApp': 'Download App',
|
||||||
|
'navigation.downloadAppAppleSilicon':
|
||||||
|
'Download App for Macs with Apple Silicon',
|
||||||
'navigation.open': 'Open navigation',
|
'navigation.open': 'Open navigation',
|
||||||
'navigation.close': 'Close navigation',
|
'navigation.close': 'Close navigation',
|
||||||
'navigation.expand': 'Expand sidebar',
|
'navigation.expand': 'Expand sidebar',
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import { ArrowRight, Columns3, GitBranch, ListChecks } from 'lucide-vue-next';
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Columns3,
|
||||||
|
Download,
|
||||||
|
GitBranch,
|
||||||
|
ListChecks,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import AppLogo from '@/components/AppLogo.vue';
|
import AppLogo from '@/components/AppLogo.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { useMacDownload } from '@/composables/useMacDownload';
|
||||||
import { dashboard, home, login } from '@/routes/index';
|
import { dashboard, home, login } from '@/routes/index';
|
||||||
|
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
const { macDownloadUrl, showMacDownload } = useMacDownload();
|
||||||
|
|
||||||
const features = computed(() => [
|
const features = computed(() => [
|
||||||
{
|
{
|
||||||
@@ -112,16 +120,38 @@ const previewColumns = computed(() => [
|
|||||||
{{ t('welcome.description') }}
|
{{ t('welcome.description') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button as-child size="lg">
|
<div
|
||||||
<Link v-if="$page.props.auth.user" :href="dashboard()">
|
class="flex flex-wrap items-start justify-center gap-3"
|
||||||
{{ t('welcome.action.dashboard') }}
|
>
|
||||||
<ArrowRight aria-hidden="true" />
|
<Button as-child size="lg">
|
||||||
</Link>
|
<Link
|
||||||
<Link v-else :href="login()">
|
v-if="$page.props.auth.user"
|
||||||
{{ t('welcome.action.signIn') }}
|
:href="dashboard()"
|
||||||
<ArrowRight aria-hidden="true" />
|
>
|
||||||
</Link>
|
{{ t('welcome.action.dashboard') }}
|
||||||
</Button>
|
<ArrowRight aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
<Link v-else :href="login()">
|
||||||
|
{{ t('welcome.action.signIn') }}
|
||||||
|
<ArrowRight aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
v-if="showMacDownload"
|
||||||
|
class="grid justify-items-center gap-1"
|
||||||
|
>
|
||||||
|
<Button as-child size="lg" variant="outline">
|
||||||
|
<a :href="macDownloadUrl"
|
||||||
|
><Download aria-hidden="true" />{{
|
||||||
|
t('navigation.downloadApp')
|
||||||
|
}}</a
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('welcome.downloadAppHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p class="text-caption text-muted-foreground">
|
<p class="text-caption text-muted-foreground">
|
||||||
{{ t('welcome.actionHint') }}
|
{{ t('welcome.actionHint') }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -90,10 +90,32 @@ if (serve) {
|
|||||||
const { en } = await server.ssrLoadModule(
|
const { en } = await server.ssrLoadModule(
|
||||||
'/resources/js/locales/en.ts',
|
'/resources/js/locales/en.ts',
|
||||||
);
|
);
|
||||||
|
const { isMacDownloadAvailable, macDownloadUrl } =
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useMacDownload.ts',
|
||||||
|
);
|
||||||
const { default: Welcome } = await server.ssrLoadModule(
|
const { default: Welcome } = await server.ssrLoadModule(
|
||||||
'/resources/js/pages/Welcome.vue',
|
'/resources/js/pages/Welcome.vue',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
macDownloadUrl,
|
||||||
|
'https://petsch-studios.nbg1.your-objectstorage.com/releases/macos/aarch64-apple-darwin/KanbanGit.dmg',
|
||||||
|
);
|
||||||
|
const macUserAgent =
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15';
|
||||||
|
assert.equal(isMacDownloadAvailable(macUserAgent, 0, undefined), true);
|
||||||
|
assert.equal(isMacDownloadAvailable(macUserAgent, 0, true), false);
|
||||||
|
assert.equal(isMacDownloadAvailable(macUserAgent, 5, undefined), false);
|
||||||
|
assert.equal(
|
||||||
|
isMacDownloadAvailable(
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0)',
|
||||||
|
0,
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
const render = async (url, localization = createLocalization()) => {
|
const render = async (url, localization = createLocalization()) => {
|
||||||
const response = await createInertiaApp({
|
const response = await createInertiaApp({
|
||||||
page: makePage(url),
|
page: makePage(url),
|
||||||
|
|||||||
Reference in New Issue
Block a user