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
54 changes: 49 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ name: Release
# then `npm trust github …` — see docs/releasing.md. CI owns every later release.

on:
workflow_dispatch:
push:
branches: [main]

permissions: {}

jobs:
verify:
if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }}
if: ${{ github.event_name == 'push' && !contains(github.event.head_commit.message, '[skip ci]') }}
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 10
permissions:
Expand All @@ -39,15 +40,19 @@ jobs:
- run: pnpm exec vp run ready

scan:
if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }}
if: ${{ github.event_name == 'push' && !contains(github.event.head_commit.message, '[skip ci]') }}
permissions:
contents: read
uses: uinaf/.github/.github/workflows/scan.yml@main

release:
if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }}
if: >-
${{ always() && !cancelled() &&
((github.event_name == 'push' && needs.verify.result == 'success' && needs.scan.result == 'success') ||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')) }}
needs: [verify, scan]
runs-on: blacksmith-2vcpu-ubuntu-2404
# npm provenance requires a GitHub-hosted runner.
runs-on: ubuntu-24.04
timeout-minutes: 15
environment: release
concurrency:
Expand All @@ -61,6 +66,7 @@ jobs:
with:
persist-credentials: false
fetch-depth: 0
ref: ${{ github.sha }}
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with:
standalone: true
Expand All @@ -71,7 +77,20 @@ jobs:
cache: false
run-install: |
- args: ["--frozen-lockfile"]
- name: Validate the existing CLI 0.6.2 release state
if: github.event_name == 'workflow_dispatch'
id: recovery
env:
GH_TOKEN: ${{ github.token }}
run: node apps/cli/scripts/recover-0.6.2.ts preflight
- name: Verify and pack the recovery build
if: github.event_name == 'workflow_dispatch'
run: |
pnpm run verify
cd apps/cli
npm pack --ignore-scripts
- name: Build and pack smoke
if: github.event_name == 'push'
run: |
set -euo pipefail
pnpm --filter @uinaf/attach-cli build
Expand All @@ -92,10 +111,12 @@ jobs:
homebrew-tap
permission-contents: write
- name: Authorize release writes
if: github.event_name == 'push'
env:
GH_TOKEN: ${{ steps.release-bot.outputs.token }}
run: gh auth setup-git
- id: semantic
if: github.event_name == 'push'
uses: cycjimmy/semantic-release-action@b12c8f6015dc215fe37bc154d4ad456dd3833c90 # v6.0.0
with:
semantic_version: 25.0.3
Expand All @@ -110,11 +131,34 @@ jobs:
GITHUB_TOKEN: ${{ steps.release-bot.outputs.token }}
GH_TOKEN: ${{ steps.release-bot.outputs.token }}

- name: Publish the missing npm package with OIDC
if: github.event_name == 'workflow_dispatch' && steps.recovery.outputs.publish == 'true'
working-directory: apps/cli
run: npm publish --ignore-scripts --access public --provenance
- name: Verify published package integrity and provenance
if: github.event_name == 'workflow_dispatch'
env:
GH_TOKEN: ${{ github.token }}
run: node apps/cli/scripts/recover-0.6.2.ts published
Comment thread
altaywtf marked this conversation as resolved.
- name: Create the missing GitHub release without changing its tag
if: github.event_name == 'workflow_dispatch' && steps.recovery.outputs.release == 'true'
env:
GH_TOKEN: ${{ steps.release-bot.outputs.token }}
RECOVERY_SHA: ${{ github.sha }}
run: |
gh release create cli-v0.6.2 --verify-tag --title cli-v0.6.2 --generate-notes --notes-start-tag cli-v0.6.1 \
--notes "Recovered npm publication from build $RECOVERY_SHA. Package inputs match the unchanged tag cli-v0.6.2 at its signed release commit; provenance and npm gitHead identify the recovery build."
- name: Verify recovered release parity
if: github.event_name == 'workflow_dispatch'
env:
GH_TOKEN: ${{ github.token }}
run: node apps/cli/scripts/recover-0.6.2.ts complete

- name: Resolve release target
id: release-target
env:
GH_TOKEN: ${{ steps.release-bot.outputs.token }}
NEW_RELEASE_TAG: ${{ steps.semantic.outputs.new_release_git_tag }}
NEW_RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && 'cli-v0.6.2' || steps.semantic.outputs.new_release_git_tag }}
run: |
set -euo pipefail
tag="$NEW_RELEASE_TAG"
Expand Down
201 changes: 201 additions & 0 deletions apps/cli/scripts/recover-0.6.2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// Fixed recovery for the tag created before npm rejected the self-hosted runner.
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { appendFileSync, readFileSync } from "node:fs";

const tag = "cli-v0.6.2";
const sha = "7a0c78d311323ae0677389405c55f9d04c920928";
const version = "0.6.2";
const repo = "uinaf/attach";

function record(value: unknown): Record<string, unknown> {
assert(value !== null && typeof value === "object" && !Array.isArray(value), "Expected object");
return Object.fromEntries(Object.entries(value));
}

export async function lookup(url: string, token?: string): Promise<Record<string, unknown> | null> {
const response = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
signal: AbortSignal.timeout(30_000),
});
if (response.status === 404) return null;
assert(response.ok, `Lookup failed: HTTP ${response.status} at ${url}`);
return record(await response.json());
}

export async function lookupPublished(url: string): Promise<Record<string, unknown> | null> {
// Registry attestations can remain 404 briefly after npm accepts publication.
for (let attempt = 0; attempt < 12; attempt++) {
const result = await lookup(url);
if (result !== null || attempt === 11) return result;
await new Promise((resolve) => setTimeout(resolve, 5_000));
}
return null;
}

export function checkInputs(files: string[]): void {
const recoveryFiles = new Set([
".github/workflows/release.yml",
"docs/releasing.md",
"apps/cli/scripts/recover-0.6.2.ts",
"apps/cli/test/recovery.test.ts",
]);
for (const file of files) assert(recoveryFiles.has(file), `Package input changed: ${file}`);
}

export function checkPackage(
pkg: Record<string, unknown>,
integrity?: string,
buildSha?: string,
): void {
assert.equal(pkg.name, "@uinaf/attach-cli");
assert.equal(pkg.version, version);
if (buildSha) assert.equal(pkg.gitHead, buildSha, "npm gitHead differs from the recovery build");
const dist = record(pkg.dist);
assert.equal(typeof dist.integrity, "string");
if (integrity)
assert.equal(dist.integrity, integrity, "Published tarball differs from verified build");
const attestations = record(dist.attestations);
assert.equal(record(attestations.provenance).predicateType, "https://slsa.dev/provenance/v1");
}

// npm validates the signed bundle at ingestion; check its registry-served claims here.
export function checkProvenance(
response: Record<string, unknown>,
integrity: string,
buildSha: string,
): void {
assert(Array.isArray(response.attestations), "Missing npm attestations");
const entries = response.attestations
.map(record)
.filter((entry) => entry.predicateType === "https://slsa.dev/provenance/v1");
assert.equal(entries.length, 1, "Expected one npm provenance statement");
const envelope = record(record(entries[0]?.bundle).dsseEnvelope);
assert.equal(envelope.payloadType, "application/vnd.in-toto+json");
assert.equal(typeof envelope.payload, "string");
const statement = record(
JSON.parse(Buffer.from(String(envelope.payload), "base64").toString("utf8")),
);
assert.equal(statement._type, "https://in-toto.io/Statement/v1");
assert.equal(statement.predicateType, "https://slsa.dev/provenance/v1");
assert.match(integrity, /^sha512-[A-Za-z0-9+/]{86}==$/);
assert.deepEqual(
statement.subject,
[
{
name: "pkg:npm/%40uinaf/attach-cli@0.6.2",
digest: { sha512: Buffer.from(integrity.slice(7), "base64").toString("hex") },
},
],
"Provenance subject differs from the npm artifact",
);
const predicate = record(statement.predicate);
const definition = record(predicate.buildDefinition);
assert.equal(
definition.buildType,
"https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1",
);
assert.deepEqual(
record(definition.externalParameters).workflow,
{
ref: "refs/heads/main",
repository: `https://github.com/${repo}`,
path: ".github/workflows/release.yml",
},
"Provenance workflow differs from recovery",
);
assert.equal(
record(record(definition.internalParameters).github).event_name,
"workflow_dispatch",
);
assert.deepEqual(
definition.resolvedDependencies,
[
{
uri: `git+https://github.com/${repo}@refs/heads/main`,
digest: { gitCommit: buildSha },
},
],
"Provenance commit differs from the event commit",
);
assert.equal(
record(record(predicate.runDetails).builder).id,
"https://github.com/actions/runner/github-hosted",
);
}

export function checkRelease(release: Record<string, unknown>): void {
assert.equal(release.tag_name, tag);
assert.equal(release.draft, false);
assert.equal(release.prerelease, false);
assert.equal(release.immutable, true);
}

async function main(): Promise<void> {
assert.equal(process.env.GITHUB_REPOSITORY, repo);
assert.equal(process.env.GITHUB_REF, "refs/heads/main");
const buildSha = process.env.GITHUB_SHA;
assert(buildSha && /^[a-f0-9]{40}$/.test(buildSha), "Expected event commit SHA");
const git = (...args: string[]) => execFileSync("git", args, { encoding: "utf8" }).trim();
assert.equal(git("rev-parse", "HEAD"), buildSha, "Checkout differs from the event commit");
git("merge-base", "--is-ancestor", sha, buildSha);
checkInputs(git("diff", "--name-only", "-z", sha, buildSha).split("\0").filter(Boolean));
git("diff", "--exit-code", "HEAD", "--");
const token = process.env.GH_TOKEN;
assert(token, "Read token required for exact GitHub lookups");
const github = (path: string) => lookup(`https://api.github.com/repos/${repo}/${path}`, token);
const ref = await github(`git/ref/tags/${tag}`);
assert(ref, "Release tag missing");
assert.equal(record(ref.object).sha, sha);
assert.equal(record(ref.object).type, "commit");
const commit = await github(`commits/${sha}`);
assert(commit);
assert.equal(record(record(commit.commit).verification).verified, true);
const comparison = await github(`compare/${buildSha}...main`);
assert(comparison);
assert(
["ahead", "identical"].includes(String(comparison.status)),
"Recovery commit is not an ancestor of main",
);
const file = await github(`contents/apps/cli/package.json?ref=${sha}`);
assert(file && typeof file.content === "string");
const manifest = record(JSON.parse(Buffer.from(file.content, "base64").toString("utf8")));
assert.equal(manifest.name, "@uinaf/attach-cli");
assert.equal(manifest.version, version);

const mode = process.argv[2];
assert(mode === "preflight" || mode === "published" || mode === "complete");
const registry = mode === "published" ? lookupPublished : lookup;
const pkg = await registry("https://registry.npmjs.org/@uinaf%2fattach-cli/0.6.2");
const release = await github(`releases/tags/${tag}`);
if (pkg) {
checkPackage(pkg, undefined, buildSha);
const attestation = await registry(
"https://registry.npmjs.org/-/npm/v1/attestations/@uinaf%2fattach-cli@0.6.2",
);
assert(attestation, "npm provenance bundle is missing");
checkProvenance(attestation, String(record(pkg.dist).integrity), buildSha);
}
if (release) checkRelease(release);
if (mode === "preflight") {
assert(process.env.GITHUB_OUTPUT);
appendFileSync(
process.env.GITHUB_OUTPUT,
`publish=${pkg === null}\nrelease=${release === null}\n`,
);
return;
}
assert(pkg, "npm package is still missing");
const tarball = readFileSync("apps/cli/uinaf-attach-cli-0.6.2.tgz");
checkPackage(pkg, `sha512-${createHash("sha512").update(tarball).digest("base64")}`, buildSha);
if (mode === "complete") {
assert(release, "GitHub release is still missing");
checkRelease(release);
console.log(
"cli-v0.6.2: unchanged package inputs, recovery build integrity and immutable release verified",
);
}
}

if (import.meta.main) await main();
Loading