Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -578,7 +578,19 @@ jobs:
# run: source. tests/ci-workflows.test.ts enforces this repo-wide.
RELEASE_VERSION: ${{ inputs.version }}
run: |
gh release upload "v${RELEASE_VERSION}" dist/release/* --clobber
set -euo pipefail
release_tag="v${RELEASE_VERSION}"
gh release upload "$release_tag" dist/release/* --clobber

# A published release is immutable: GitHub rejects every later asset upload
# with HTTP 422, which is why v2.55.0 through v2.60.0 shipped with zero
# assets and left the desktop updater without anything to download. The
# release is therefore created as a draft and becomes public here, once the
# verified bundle is attached. The only edit permitted is this flip — the
# notes still come from the validated notes file written at creation.
if [ "$(gh release view "$release_tag" --json isDraft --jq .isDraft)" = "true" ]; then
gh release edit "$release_tag" --draft=false
fi

publish:
needs: [validate-dispatch, verify-release]
Expand Down Expand Up @@ -981,6 +993,9 @@ jobs:
exit 1
fi
else
gh release create "$release_tag" --target "$GITHUB_SHA" --title "$release_tag" \
# Draft first. Publication freezes a release under GitHub's immutable
# releases, so attach-release attaches the verified bundle to the draft
# and publishes it afterwards.
gh release create "$release_tag" --draft --target "$GITHUB_SHA" --title "$release_tag" \
--notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag}
fi
9 changes: 8 additions & 1 deletion tests/ci-workflows/ci-workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1033,7 +1033,14 @@ describe("GitHub Actions hardening", () => {
expect(releaseNotesBuilder).toContain("release changelog failed coverage validation");

expect(workflow).toMatch(/gh release create[\s\S]*?--notes-file "\$notes_file"/);
expect(workflow).not.toContain("gh release edit");
// The release is created as a draft and published only after the verified
// bundle is attached, because a published release is immutable and rejects
// every later upload. That draft flip is the one edit allowed: notes still
// come from the validated notes file, never from an edit or a regeneration.
for (const edit of workflow.match(/gh release edit[^\n]*/g) ?? []) {
expect(edit).toContain("--draft=false");
expect(edit).not.toContain("--notes");
}
expect(workflow).not.toContain("--generate-notes");

const createStep = workflow
Expand Down
16 changes: 16 additions & 0 deletions tests/ci-workflows/release-pipeline-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,22 @@ describe("release pipeline contract", () => {
expect(upload).toBeGreaterThan(receiptCheck);
});

test("the release is a draft until the verified assets are attached", () => {
// GitHub freezes a release the moment it is published: every later asset upload
// comes back HTTP 422 "Cannot upload assets to an immutable release". Creating
// the release published and uploading afterwards is what left v2.55.0 through
// v2.60.0 with zero assets and the desktop updater with nothing to fetch.
const releaseText = readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8");
const createStep = releaseText.split("- name: Create GitHub release")[1] ?? "";
expect(createStep).toMatch(/gh release create "\$release_tag" --draft/);

const attachRun = (release.jobs?.["attach-release"]?.steps ?? [])
.map(candidate => candidate.run ?? "")
.find(run => run.includes("gh release upload")) ?? "";
expect(attachRun).toContain("--draft=false");
expect(attachRun.indexOf("gh release upload")).toBeLessThan(attachRun.indexOf("--draft=false"));
});

test("a partial publication has a recorded, explicit recovery path", () => {
const releaseText = readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8");
// The only way npm publish is ever skipped: an explicit recovery input, requiring
Expand Down
Loading