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
56 changes: 52 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
name: Release

on:
workflow_dispatch:
inputs:
recover_tag:
description: Recover the verified 9.1.0 release without moving its tag
required: true
type: choice
options:
- v9.1.0
push:
branches:
- main
Expand All @@ -17,33 +25,47 @@ jobs:
outputs:
publish: ${{ steps.resolve.outputs.publish }}
tag: ${{ steps.resolve.outputs.tag }}
recovery: ${{ steps.resolve.outputs.recovery }}
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Resolve release tag
id: resolve
shell: bash
env:
RECOVERY_TAG: ${{ inputs.recover_tag }}
run: |
set -euo pipefail
version="$(node -p "require('./package.json').version")"
tag="v${version}"
echo "tag=${tag}" >> "$GITHUB_OUTPUT"

if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
if [[ "${GITHUB_REF}" != "refs/heads/main" || "${RECOVERY_TAG}" != "v9.1.0" || "${RECOVERY_TAG}" != "${tag}" ]]; then
echo "::error::Release recovery requires main and the exact v9.1.0 tag."
exit 1
fi
echo "publish=true" >> "$GITHUB_OUTPUT"
echo "recovery=true" >> "$GITHUB_OUTPUT"
elif [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
if [[ "${GITHUB_REF_NAME}" != "${tag}" ]]; then
echo "::error::Release tag/version mismatch: tag=${GITHUB_REF_NAME}, package.json=${version}."
exit 1
fi
echo "publish=true" >> "$GITHUB_OUTPUT"
echo "recovery=false" >> "$GITHUB_OUTPUT"
else
echo "publish=false" >> "$GITHUB_OUTPUT"
echo "recovery=false" >> "$GITHUB_OUTPUT"
fi

verify-main-and-tag:
needs: decide
runs-on: ubuntu-latest
timeout-minutes: 30
env:
FLOW_RELEASE_RECOVERY_TAG: ${{ needs.decide.outputs.recovery == 'true' && needs.decide.outputs.tag || '' }}
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -71,7 +93,16 @@ jobs:

- name: Rebuild release candidate
timeout-minutes: 2
run: bun pm pack --destination .
shell: bash
run: |
set -euo pipefail
bun pm pack --destination .
if [[ -n "${FLOW_RELEASE_RECOVERY_TAG}" ]]; then
bun run scripts/restore-exact-release-artifact.ts \
opencode-plugin-flow-9.1.0.tgz \
evals/qualification/bundles/qb1-86bbc934222c2715d315521ff5041edc718eba6492ff679db50a6d494fc1bd6d/objects/sha256-0b7f7a66bf1910b5d0da3a235f891d532b4aa352a2dbd25e398325c18e051b24 \
sha256:0b7f7a66bf1910b5d0da3a235f891d532b4aa352a2dbd25e398325c18e051b24
fi

- name: Report release evidence readiness without publishing
timeout-minutes: 5
Expand All @@ -80,13 +111,20 @@ jobs:
set -euo pipefail
tarball="$(ls opencode-plugin-flow-*.tgz)"
bun run release:metadata -- --artifact "$tarball"
bun run eval:canary -- verify --artifact "$tarball" --mode dry-run
if [[ -n "${FLOW_RELEASE_RECOVERY_TAG}" ]]; then
bun run release:metadata -- --tag "$FLOW_RELEASE_RECOVERY_TAG" --artifact "$tarball" --canary evals/canary/9.1.0.json
bun run eval:canary -- verify --artifact "$tarball" --mode strict
else
bun run eval:canary -- verify --artifact "$tarball" --mode dry-run
fi

release:
needs: [decide, verify-main-and-tag]
if: needs.decide.outputs.publish == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
env:
FLOW_RELEASE_RECOVERY_TAG: ${{ needs.decide.outputs.recovery == 'true' && needs.decide.outputs.tag || '' }}
concurrency:
group: release-publication
cancel-in-progress: false
Expand Down Expand Up @@ -140,6 +178,12 @@ jobs:
run: |
set -euo pipefail
bun pm pack --destination .
if [[ -n "${FLOW_RELEASE_RECOVERY_TAG}" ]]; then
bun run scripts/restore-exact-release-artifact.ts \
opencode-plugin-flow-9.1.0.tgz \
evals/qualification/bundles/qb1-86bbc934222c2715d315521ff5041edc718eba6492ff679db50a6d494fc1bd6d/objects/sha256-0b7f7a66bf1910b5d0da3a235f891d532b4aa352a2dbd25e398325c18e051b24 \
sha256:0b7f7a66bf1910b5d0da3a235f891d532b4aa352a2dbd25e398325c18e051b24
fi

- name: Restore release record from this workflow run
id: restore
Expand Down Expand Up @@ -177,10 +221,14 @@ jobs:
elif [ -f "evals/qualification/patches/${version}.json" ]; then
evidence=(--patch "evals/qualification/patches/${version}.json")
fi
commit="${GITHUB_SHA}"
if [[ -n "${FLOW_RELEASE_RECOVERY_TAG}" ]]; then
commit="$(git rev-parse "${FLOW_RELEASE_RECOVERY_TAG}^{commit}")"
fi
bun run scripts/release.ts init .release-state \
--artifact "$tarball" \
"${evidence[@]}" \
--commit "${GITHUB_SHA}"
--commit "$commit"

- name: Persist release record before publication
if: steps.restore.outputs.restored != 'true'
Expand Down
118 changes: 118 additions & 0 deletions scripts/release-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const MUTATION_ATTEMPTS = 3;
const RETRY_DELAY_MS = 5_000;
const MAX_COMMAND_OUTPUT_BYTES = 1_000_000;
const MAX_RELEASE_PAGES = 10;
const RECOVERY_TAG = "v9.1.0";
const RECOVERY_TAG_OBJECT = "c629deb583185b977908f2203fab0caee69484a9";
const RECOVERY_TAG_COMMIT = "727308d2ccd5761f03024341328cff06887ebae1";

export type CommandResult = {
readonly exitCode: number;
Expand Down Expand Up @@ -47,6 +50,22 @@ export type ReleaseRefEvidence = {
readonly mainCommitSha: string;
};

export type RecoveryRefEvidence = {
readonly expectedTag: string;
readonly requestedTag: string;
readonly eventName: string;
readonly eventRefType: string;
readonly eventRefName: string;
readonly eventSha: string;
readonly headSha: string;
readonly localTagObjectSha: string;
readonly localTagCommitSha: string;
readonly remoteTagObjectSha: string;
readonly remoteTagCommitSha: string;
readonly mainCommitSha: string;
readonly tagAncestorOfMain: boolean;
};

type NpmPublicationInput = {
readonly packageName: string;
readonly packageVersion: string;
Expand Down Expand Up @@ -208,6 +227,38 @@ export function releaseRefIssue(evidence: ReleaseRefEvidence): string | null {
return null;
}

export function releaseRecoveryRefIssue(
evidence: RecoveryRefEvidence,
requireCurrentMain: boolean,
): string | null {
if (
evidence.eventName !== "workflow_dispatch" ||
evidence.eventRefType !== "branch" ||
evidence.eventRefName !== "main"
)
return "Release recovery requires a dispatch from the main branch.";
if (
evidence.expectedTag !== RECOVERY_TAG ||
evidence.requestedTag !== evidence.expectedTag ||
evidence.eventSha !== evidence.headSha
)
return "Release recovery input or checkout differs from the dispatch.";
if (
evidence.localTagObjectSha !== RECOVERY_TAG_OBJECT ||
evidence.localTagCommitSha !== RECOVERY_TAG_COMMIT
)
return "Release recovery tag no longer identifies the pinned 9.1.0 release.";
if (evidence.localTagObjectSha !== evidence.remoteTagObjectSha)
return "The remote tag object no longer matches the checked-out release tag.";
if (evidence.localTagCommitSha !== evidence.remoteTagCommitSha)
return "The remote tag commit no longer matches the checked-out release tag.";
if (evidence.tagAncestorOfMain !== true)
return "Release recovery tag is not an ancestor of current main.";
if (requireCurrentMain && evidence.mainCommitSha !== evidence.headSha)
return "Release recovery checkout is not the current origin/main commit.";
return null;
}

async function revParse(
runtime: PublicationRuntime,
revision: string,
Expand Down Expand Up @@ -286,6 +337,73 @@ export async function verifyReleaseRef(
}
}

export async function verifyReleaseRecoveryRef(
tag: string,
runtime: PublicationRuntime,
requireCurrentMain = true,
): Promise<RecoveryRefEvidence> {
const packageJson = JSON.parse(await readFile("package.json", "utf8")) as {
version?: unknown;
};
if (typeof packageJson.version !== "string")
throw new Error("package.json does not contain a release version.");
if (
tag !== `v${packageJson.version}` ||
tag !== process.env.FLOW_RELEASE_RECOVERY_TAG
)
throw new Error("Release recovery tag differs from package or dispatch.");
const runIdentity = `${process.env.GITHUB_RUN_ID ?? "local"}-${process.env.GITHUB_RUN_ATTEMPT ?? "1"}`;
const mainRef = `refs/flow-release/${runIdentity}/recovery-main`;
const tagRef = `refs/flow-release/${runIdentity}/recovery-tag`;
const fetchArgs = [
"fetch",
"--force",
"--no-tags",
"origin",
`+refs/heads/main:${mainRef}`,
`+refs/tags/${tag}:${tagRef}`,
];
await checkedCommand(runtime, "git", fetchArgs, REMOTE_COMMAND_TIMEOUT_MS);
try {
const tagAncestorOfMain = await runtime.run(
"git",
["merge-base", "--is-ancestor", `${tagRef}^{commit}`, mainRef],
LOCAL_COMMAND_TIMEOUT_MS,
);
if (
tagAncestorOfMain.timedOut ||
(tagAncestorOfMain.exitCode !== 0 && tagAncestorOfMain.exitCode !== 1)
)
throw new Error("Release recovery ancestry check failed.");
const evidence: RecoveryRefEvidence = {
expectedTag: `v${packageJson.version}`,
requestedTag: process.env.FLOW_RELEASE_RECOVERY_TAG ?? "",
eventName: process.env.GITHUB_EVENT_NAME ?? "",
eventRefType: process.env.GITHUB_REF_TYPE ?? "",
eventRefName: process.env.GITHUB_REF_NAME ?? "",
eventSha: process.env.GITHUB_SHA ?? "",
headSha: await revParse(runtime, "HEAD^{commit}"),
localTagObjectSha: await revParse(runtime, `refs/tags/${tag}`),
localTagCommitSha: await revParse(runtime, `refs/tags/${tag}^{commit}`),
remoteTagObjectSha: await revParse(runtime, tagRef),
remoteTagCommitSha: await revParse(runtime, `${tagRef}^{commit}`),
mainCommitSha: await revParse(runtime, `${mainRef}^{commit}`),
tagAncestorOfMain: tagAncestorOfMain.exitCode === 0,
};
const issue = releaseRecoveryRefIssue(evidence, requireCurrentMain);
if (issue) throw new Error(issue);
return evidence;
} finally {
for (const ref of [mainRef, tagRef]) {
await runtime.run(
"git",
["update-ref", "-d", ref],
LOCAL_COMMAND_TIMEOUT_MS,
);
}
}
}

async function fetchBounded(
runtime: PublicationRuntime,
input: string,
Expand Down
22 changes: 19 additions & 3 deletions scripts/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
convergeNpmPublication,
defaultRuntime,
type PublicationRuntime,
verifyReleaseRecoveryRef,
verifyReleaseRef,
} from "./release-publish.js";

Expand Down Expand Up @@ -47,6 +48,7 @@ export const ReleaseRecordSchema = z
bundles: z.string().min(1),
bundleSha256: Hash,
creationOwner: z.string().min(1),
publicationMode: z.literal("recovery").optional(),
})
.strict();
export type ReleaseRecord = z.infer<typeof ReleaseRecordSchema>;
Expand Down Expand Up @@ -170,6 +172,12 @@ export async function resumeRelease(
},
): Promise<void> {
const record = await loadRelease(directory);
const recovery = record.publicationMode === "recovery";
if (
recovery !== Boolean(process.env.FLOW_RELEASE_RECOVERY_TAG) ||
(recovery && process.env.FLOW_RELEASE_RECOVERY_TAG !== record.tag)
)
throw new Error("Release recovery mode differs from its durable record.");
await verify(record);
const github = {
repository: record.repository,
Expand All @@ -183,8 +191,11 @@ export async function resumeRelease(
],
};
const proof = async (currentMain: boolean) => {
const evidence = await verifyReleaseRef(record.tag, runtime, currentMain);
if (evidence.headSha !== record.commit)
const commit = recovery
? (await verifyReleaseRecoveryRef(record.tag, runtime, currentMain))
.localTagCommitSha
: (await verifyReleaseRef(record.tag, runtime, currentMain)).headSha;
if (commit !== record.commit)
throw new Error("Release record commit differs from checkout.");
};
const prepared = await convergeGithubRelease(
Expand Down Expand Up @@ -271,6 +282,9 @@ async function initialize(directory: string, options: Map<string, string>) {
: "evals/qualification/bundles"));
const metadata = JSON.parse(await readFile("package.json", "utf8"));
const tag = `v${metadata.version}`;
const recovery = process.env.FLOW_RELEASE_RECOVERY_TAG;
if (recovery && recovery !== tag)
throw new Error("Release recovery tag differs from package version.");
const artifact = await inspectArtifact({
repositoryRoot: process.cwd(),
tarballPath: artifactPath,
Expand All @@ -296,7 +310,8 @@ async function initialize(directory: string, options: Map<string, string>) {
record.canary !== canary ||
record.patch !== patch ||
record.feature !== feature ||
record.bundles !== bundles
record.bundles !== bundles ||
record.publicationMode !== (recovery ? "recovery" : undefined)
)
throw new Error(
"Existing release record conflicts with requested inputs.",
Expand Down Expand Up @@ -355,6 +370,7 @@ async function initialize(directory: string, options: Map<string, string>) {
bundles,
bundleSha256: evidence.bundleSha256,
creationOwner: owner(),
...(recovery ? { publicationMode: "recovery" as const } : {}),
});
await writeExclusive(join(directory, "release.json"), record);
}
Expand Down
30 changes: 30 additions & 0 deletions scripts/restore-exact-release-artifact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { unpackedManifestSha256 } from "../evals/provenance.js";

const [rebuiltPath, sealedPath, expectedSha256] = process.argv.slice(2);
if (
!rebuiltPath ||
!sealedPath ||
!/^sha256:[a-f0-9]{64}$/.test(expectedSha256 ?? "")
)
throw new Error(
"Expected rebuilt tarball, sealed bundle object, and SHA-256.",
);

const sealedBytes = await readFile(sealedPath);
const actualSha256 = `sha256:${createHash("sha256").update(sealedBytes).digest("hex")}`;
if (actualSha256 !== expectedSha256)
throw new Error("Sealed release artifact has the wrong digest.");

const [rebuiltManifest, sealedManifest] = await Promise.all([
unpackedManifestSha256(rebuiltPath),
unpackedManifestSha256(sealedPath),
]);
if (rebuiltManifest !== sealedManifest)
throw new Error(
"Rebuilt package contents differ from the qualified artifact.",
);

await writeFile(rebuiltPath, sealedBytes);
console.log(`Restored exact release artifact ${actualSha256}.`);
4 changes: 3 additions & 1 deletion tests/documentation-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,9 @@ describe("Flow documentation contract", () => {
expect(release).toMatch(/^ {2}push:\n {4}branches:/m);
expect(release).toContain("tags:");
expect(release).toMatch(/tag="v\$\{version\}"/);
expect(release).toMatch(/--commit "\$\{GITHUB_SHA\}"/);
expect(release).toMatch(/commit="\$\{GITHUB_SHA\}"/);
expect(release).toContain('commit="$(git rev-parse "');
expect(release).toContain('--commit "$commit"');
expect(release).toContain("Verify selected release evidence");
expect(release).toContain("bun run eval:canary -- verify");
expect(release).toContain("--mode dry-run");
Expand Down
Loading
Loading