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
3 changes: 3 additions & 0 deletions .agents/plans/03-assurance-hardening/decisions.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,6 @@ ts phase decision why evidence result
2026-08-28T05:41:49Z phase-8 made the sealed bundle release authority digest records could be internally consistent without proving their verdict regradeQualificationBundle; assertQualificationBundle; release workflow verifier reopens all objects, regrades 76 attempts, rederives canary, provenance, usage, authority, and decision; forged decision and missing source bundles fail
2026-08-28T05:55:21Z phase-8 fixed all actionable release-authority review findings ignored fixed roles, unsafe reader artifacts, unbounded references, stale campaigns, ambiguous bundles, swallowed corruption, and script cycles weakened the gate fixed-role equality; read-time tar scan; total cap; seven-day policy; unique SHA-emitting selection; injected decision authority current release source must match the sealed closure and exactly one clean fresh bundle must reproduce
2026-08-28T05:55:21Z phase-8 completed independent release regrading verification forged decisions, omitted sources, contradictory roles/manifests, stale campaigns, digest-only records, and corrupted siblings needed executable rejection positive 76-cell CLI plus adversarial reseals; full check; replay; live smoke; final reviews 598 pass, 1 intentional skip, 13 of 13 replays, live smoke pass, no production blockers
2026-08-28T06:01:16Z phase-8 merged independent release-time regrading the exact head passed every required CI job and an isolated frozen-install verifier PR 56; merge f505b0d merged to main
2026-08-28T06:08:00Z phase-9 selected a bounded publication state machine inline shell inferred absence from errors, lacked current-main proof, and could destructively clobber assets two architecture candidates; independent arena judge repository-owned TypeScript ref proof and postcondition-driven npm/GitHub reconciliation; no blind mutation retries or clobber
2026-08-28T06:25:00Z phase-9 made the exact draft the durable transaction proof separate npm and GitHub steps could not recover after npm succeeded and main advanced adversarial multi-model review; draft-inclusive GitHub API contract full main proof creates exact draft and precedes npm; tag-only proof finalizes it; conflicts, extras, prereleases, pending digests, and superseded workflow failures fail or reconcile explicitly
106 changes: 50 additions & 56 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ permissions:
jobs:
decide:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
publish: ${{ steps.resolve.outputs.publish }}
tag: ${{ steps.resolve.outputs.tag }}
Expand Down Expand Up @@ -42,9 +43,12 @@ jobs:
verify-main-and-tag:
needs: decide
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0

- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
Expand All @@ -58,15 +62,19 @@ jobs:
bun-version: 1.3.14

- name: Install dependencies
timeout-minutes: 5
run: bun install --frozen-lockfile

- name: Run deterministic checks
timeout-minutes: 10
run: bun run check

- name: Rebuild release candidate
timeout-minutes: 2
run: bun pm pack --destination .

- name: Report release evidence readiness without publishing
timeout-minutes: 5
shell: bash
run: |
set -euo pipefail
Expand All @@ -78,12 +86,18 @@ jobs:
needs: [decide, verify-main-and-tag]
if: needs.decide.outputs.publish == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: release-publication
cancel-in-progress: false
permissions:
contents: write
id-token: write
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0

- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
Expand All @@ -93,6 +107,7 @@ jobs:
package-manager-cache: false

- name: Set up npm trusted publishing support
timeout-minutes: 5
run: |
npm install --global npm@11.18.0
node --version
Expand All @@ -104,24 +119,30 @@ jobs:
bun-version: 1.3.14

- name: Install dependencies
timeout-minutes: 5
run: bun install --frozen-lockfile

- name: Validate release tag and changelog
timeout-minutes: 2
run: bun run release:metadata -- --tag "${{ needs.decide.outputs.tag }}" --notes-file release-notes.md

- name: Run checks
timeout-minutes: 10
run: bun run check

- name: Run pinned live OpenCode smoke
timeout-minutes: 5
run: bun run smoke:live

- name: Prepare package
timeout-minutes: 2
shell: bash
run: |
set -euo pipefail
bun pm pack --destination .

- name: Verify independently regraded qualification bundle and fresh canary
timeout-minutes: 5
shell: bash
run: |
set -euo pipefail
Expand All @@ -134,71 +155,44 @@ jobs:
--canary "evals/canary/${version}.json"
shasum -a 256 "$tarball" > "${tarball}.sha256"

- name: Publish to npm
- name: Prepare exact GitHub release draft
timeout-minutes: 15
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
package_name="$(node -p "require('./package.json').name")"
package_version="$(node -p "require('./package.json').version")"
tag="${{ needs.decide.outputs.tag }}"
tarball="$(ls opencode-plugin-flow-*.tgz)"
local_integrity="$(node --input-type=module -e '
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
process.stdout.write("sha512-" + createHash("sha512").update(readFileSync(process.argv[1])).digest("base64"));
' "$tarball")"
registry_error_file="$(mktemp)"

if published_integrity="$(npm view "${package_name}@${package_version}" dist.integrity 2>"$registry_error_file")"; then
if [[ "$published_integrity" != "$local_integrity" ]]; then
echo "::error::npm already contains ${package_name}@${package_version} with different tarball integrity."
exit 1
fi
echo "npm already contains the exact ${package_name}@${package_version} tarball; skipping publish."
else
registry_error="$(<"$registry_error_file")"
if [[ "$registry_error" != *"E404"* && "$registry_error" != *"No match found"* ]]; then
printf '%s\n' "$registry_error" >&2
exit 1
fi
npm publish "$tarball" --access public
fi
bun run scripts/release-publish.ts github-prepare \
--tag "$tag" \
--commit "${GITHUB_SHA}" \
--notes release-notes.md \
--asset "$tarball" \
--asset "${tarball}.sha256"

- name: Publish to npm after a current-main proof
timeout-minutes: 10
shell: bash
run: |
set -euo pipefail
tarball="$(ls opencode-plugin-flow-*.tgz)"
bun run scripts/release-publish.ts npm \
--artifact "$tarball" \
--tag "${{ needs.decide.outputs.tag }}"

- name: Publish GitHub release assets
- name: Publish the exact GitHub release draft
timeout-minutes: 15
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
tag="${{ needs.decide.outputs.tag }}"
tarball="$(ls opencode-plugin-flow-*.tgz)"
release_query_error_file="$(mktemp)"

retry_idempotent() {
local attempt=1
local maximum_attempts=5
local retry_delay_seconds

until "$@"; do
if (( attempt >= maximum_attempts )); then
return 1
fi
retry_delay_seconds=$((attempt * 15))
echo "Retrying idempotent GitHub release operation in ${retry_delay_seconds}s (attempt $((attempt + 1))/${maximum_attempts})."
sleep "$retry_delay_seconds"
attempt=$((attempt + 1))
done
}

if gh release view "$tag" >/dev/null 2>"$release_query_error_file"; then
retry_idempotent gh release edit "$tag" --title "$tag" --notes-file release-notes.md
else
release_query_error="$(<"$release_query_error_file")"
if [[ "$release_query_error" != *"release not found"* && "$release_query_error" != *"HTTP 404"* ]]; then
printf '%s\n' "$release_query_error" >&2
exit 1
fi
retry_idempotent gh release create "$tag" --title "$tag" --notes-file release-notes.md --target "${GITHUB_SHA}"
fi

retry_idempotent gh release upload "$tag" "$tarball" --clobber
retry_idempotent gh release upload "$tag" "${tarball}.sha256" --clobber
bun run scripts/release-publish.ts github-publish \
--tag "$tag" \
--commit "${GITHUB_SHA}" \
--notes release-notes.md \
--asset "$tarball" \
--asset "${tarball}.sha256"
10 changes: 10 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,13 @@ Release tags use `v<package-version>`. Blocking release checks include the
normal repository gate, package smoke, packed live OpenCode smoke, package
integrity generation, npm publication, and GitHub release assets. There is no
cross-version active-session gate because v6 is an explicit hard cutover.

Publication accepts both annotated and lightweight tags, but the freshly fetched
tag, workflow event, checkout, and current remote `main` tip must identify the
same commit immediately before npm publication. Network calls have explicit
deadlines. npm publication reconciles the immutable package integrity after every
result, including timeouts. GitHub publication first builds an exact draft under
the same ref proof. That draft is the recovery marker if npm succeeds and `main`
then advances. Finalization rechecks the remote tag, refuses conflicting metadata
or assets, and publishes only after every asset digest matches. Reruns converge
after partial success without replacing published bytes.
36 changes: 25 additions & 11 deletions scripts/release-monitor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ function usage() {
" --expect <names> Comma-separated workflow names to require",
" --repo <owner/repo> GitHub repository passed to gh --repo",
" --timeout <seconds> Maximum wait time (default: 900)",
" --command-timeout <seconds> Maximum one git or gh call may take (default: 30)",
" --interval <seconds> Poll interval (default: 15)",
" --once Check once and exit without polling",
" --json Print final JSON summary",
Expand All @@ -63,6 +64,7 @@ function parseArgs(argv) {
expect: null,
repo: null,
timeoutMs: 900_000,
commandTimeoutMs: 30_000,
intervalMs: 15_000,
once: false,
json: false,
Expand Down Expand Up @@ -98,6 +100,10 @@ function parseArgs(argv) {
options.intervalMs = secondsToMs(readFlag(argv, index, arg), arg);
index += 1;
break;
case "--command-timeout":
options.commandTimeoutMs = secondsToMs(readFlag(argv, index, arg), arg);
index += 1;
break;
case "--once":
options.once = true;
break;
Expand All @@ -112,6 +118,9 @@ function parseArgs(argv) {
throw new Error(`Unknown option: ${arg}`);
}
}
if (options.commandTimeoutMs > options.timeoutMs) {
throw new Error("--command-timeout cannot exceed --timeout.");
}
return {
...options,
expectedWorkflows:
Expand All @@ -127,21 +136,24 @@ function secondsToMs(value, flag) {
return seconds * 1000;
}

function run(command, args) {
function run(command, args, timeoutMs) {
const result = spawnSync(command, args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: timeoutMs,
});
if (result.status !== 0) {
throw new Error(
`${command} ${args.join(" ")} failed: ${result.stderr || result.stdout}`,
);
if (result.error || result.status !== 0) {
const errorDetail =
result.error?.code === "ETIMEDOUT"
? "timed out"
: result.error?.message || result.stderr || result.stdout;
throw new Error(`${command} ${args.join(" ")} failed: ${errorDetail}`);
}
return result.stdout.trim();
}

function resolveCommit(commit) {
return commit ?? run("git", ["rev-parse", "HEAD"]);
function resolveCommit(commit, commandTimeoutMs) {
return commit ?? run("git", ["rev-parse", "HEAD"], commandTimeoutMs);
}

function workflowName(run) {
Expand Down Expand Up @@ -180,7 +192,7 @@ function listRuns(options) {
JSON_FIELDS,
];
if (options.repo) args.push("--repo", options.repo);
return JSON.parse(run("gh", args));
return JSON.parse(run("gh", args, options.commandTimeoutMs));
}

function evaluate(runs, options) {
Expand All @@ -193,7 +205,7 @@ function evaluate(runs, options) {
const missing = required
.filter((entry) => entry.run === null)
.map((entry) => entry.name);
const failed = runsForCommit.filter(
const failed = [...byWorkflow.values()].filter(
(run) =>
run.status === "completed" && FAILURE_CONCLUSIONS.has(run.conclusion),
);
Comment on lines +208 to 211

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep separate workflow runs from masking failures

When a failed push-triggered CI run is followed by a newer workflow_dispatch run for the same commit and workflow name, latestByWorkflow selects the manual run and this filter drops the failed push run, so the monitor reports the release green. I reproduced this with distinct databaseId values and push/workflow_dispatch events. gh run list --help documents --event as filtering runs by the triggering event; use that field or the requested databaseId/attempt fields to distinguish an actual rerun from a separate run.

Useful? React with 👍 / 👎.

Expand Down Expand Up @@ -263,7 +275,7 @@ async function main(argv) {
process.stdout.write(`${usage()}\n`);
return;
}
options.commit = resolveCommit(options.commit);
options.commit = resolveCommit(options.commit, options.commandTimeoutMs);
const started = Date.now();
let lastSummary = null;
while (Date.now() - started <= options.timeoutMs) {
Expand All @@ -275,7 +287,9 @@ async function main(argv) {
if (!options.json) {
process.stderr.write(`${formatSummary(lastSummary)}\n\n`);
}
await delay(options.intervalMs);
const remainingMs = options.timeoutMs - (Date.now() - started);
if (remainingMs <= 0) break;
await delay(Math.min(options.intervalMs, remainingMs));
}
finish(lastSummary, options.json);
}
Expand Down
Loading