Skip to content

Commit 1f8e236

Browse files
feat: --build-timestamp + dev.ocx.sh continuous deploy
Add `--build-timestamp [datetime|date|none]` to `ocx package push` to attach UTC build metadata to published tags. Bare flag defaults to `datetime`; identifiers already carrying build metadata or lacking the `X.Y.Z` core are rejected (`#[non_exhaustive] Error::BuildMeta`, `require_equals=true`). Cascade behaviour unchanged — `Version::parent()` strips build before deriving rolling targets. Shared `BuildTimestampFormat` enum + `build_timestamp` helper move from `ocx_mirror` into `ocx_lib::package::version::build_meta`; mirror callers pass `None` because their version strings are pre-formatted via `normalize_version`. `Version::with_build` attaches build metadata via the typed model instead of string formatting. Add `deploy-dev.yml` triggered by every `main` push and manual `workflow_dispatch`. Always builds linux x86_64 and aarch64 (gnu+musl) for `ocx` and `ocx-mirror` via cargo-zigbuild, publishes to `dev.ocx.sh/ocx:<core>-dev_<TS>` and `dev.ocx.sh/ocx:mirror-<core>-dev_<TS>`. Dispatch input `include_cross_platform: true` extends matrix to darwin + windows (amd64/arm64). Version core from `git-cliff --bumped-version`; empty output fails loudly. Extract `oci-publish.yml` as reusable workflow handling `bare` (dev) and `cargo-dist` (prod) artifact sources; `post-release-oci-publish.yml` delegates platform-loop + push. `.github/actions/build-rust` gains `use_zigbuild: bool` so dev can cross-compile aarch64+musl from x86 ubuntu without leaving the native path. Dev publish job uses `dev.ocx.sh` GitHub Environment for secret gating (`REGISTRY_USER`/`REGISTRY_TOKEN` mapped to `OCX_AUTH_dev_ocx_sh_USER`/`_TOKEN`). CI security hardening from swarm-review: pin `messense/cargo-xwin` by digest (CWE-829), regex-validate `inputs.registry` (CWE-78), `persist-credentials: false` on checkouts (CWE-522), explicit `secrets` blocks replacing `secrets: inherit`, registry allowlist defense-in-depth, `shell: bash` on windows run steps, drop `|| true` masking git-cliff failures, hard-fail on missing artifacts, prerelease guard on publish job. Build timestamp baked into `version_tag` caller-side; the `apply_build_timestamp` input is removed for single source-of-truth across the matrix push loop. Tests: `Version::with_build` invariants (bare patch / variant pre-release / already present / no patch), publisher `apply_build_meta`, `BuildTimestampFormat` round-trip, and `test/tests/test_package_push_build_timestamp.py` covering CLI surface (datetime / date / none / bare / cascade / double-push / NoPatch). Docs: CHANGELOG `[Unreleased]` entries, ADR `adr_version_build_separator.md` → Accepted, `subsystem-package.md` adds `version/build_meta.rs` row, `subsystem-mirror.md` re-export note, dead ADR link → prose, `command-line.md` documents absent-flag clause.
1 parent 5fb23b8 commit 1f8e236

20 files changed

Lines changed: 1183 additions & 154 deletions

File tree

.claude/artifacts/adr_version_build_separator.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
## Metadata
44

5-
**Status:** Proposed
5+
**Status:** Accepted
66
**Date:** 2026-03-12
7+
**Date Accepted:** 2026-05-14
78
**Deciders:** mherwig
89
**Beads Issue:** N/A
910
**Related PRD:** N/A

.claude/rules/subsystem-cli-commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ ADR: [`adr_cli_high_low_layering.md`](../../.claude/artifacts/adr_cli_high_low_l
6969
| `lock` | Resolve project tool tags to digests and write `ocx.lock` | No | `-g/--group` |
7070
| `package pull PKGS...` | Download to object store only | N/A (is pull) | `-p` |
7171
| `package create PATH` | Bundle directory into archive | No | `-o`, `-m`, `-l`, `-j`, `--force` |
72-
| `package push -i ID LAYERS...` | Publish archive to registry | No | `-i/--identifier` (required), `-c/--cascade`, `-n`, `-m`, `-p` |
72+
| `package push -i ID LAYERS...` | Publish archive to registry | No | `-i/--identifier` (required), `-c/--cascade`, `-n`, `-m`, `-p`, `--build-timestamp [datetime\|date\|none]` |
7373
| `package describe ID` | Push description metadata | No | `--readme`, `--logo`, `--title` |
7474
| `package test -i ID LAYERS... -- CMD` | Materialize + exec locally (no registry) | **Yes** (deps only) | `-i/--identifier` (required), `-p`, `-m`, `--keep`, `-o/--output`, `--self`, `--clean` |
7575
| `package info ID` | Display description metadata | No | `--save-readme`, `--save-logo` |

.claude/rules/subsystem-mirror.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Separate crate: mirror tool standalone binary, own CLI, not part of `ocx` packag
2020
| `command/check.rs` | Dry-run sync |
2121
| `command/validate.rs` | Spec validation only |
2222
| `command/options.rs` | Shared `SyncOptions` (--exact-version, --latest, --fail-fast) |
23-
| `spec/spec.rs` | `MirrorSpec` root, `load_spec()`, extends chain resolution |
23+
| `spec/spec.rs` | `MirrorSpec` root, `load_spec()`, extends chain resolution; `build_timestamp` field uses `BuildTimestampFormat` re-exported from `ocx_lib::package::version` |
2424
| `spec/source.rs` | `Source` enum (GithubRelease, UrlIndex) |
2525
| `spec/target.rs` | `Target` (registry + repository) |
2626
| `spec/assets.rs` | `AssetPatterns` (platform → regex[] mapping) |
@@ -40,7 +40,7 @@ Separate crate: mirror tool standalone binary, own CLI, not part of `ocx` packag
4040
| `pipeline/mirror_result.rs` | `MirrorResult`: Pushed/Skipped/Failed |
4141
| `resolver.rs` | `resolve_assets()`: apply regex patterns to asset names |
4242
| `filter.rs` | `filter_versions()`: apply bounds, prerelease skip, backfill cap |
43-
| `normalizer.rs` | `normalize_version()`: add build timestamp |
43+
| `normalizer.rs` | `normalize_version()`: attach build timestamp to a version string; re-exports `build_timestamp` from `ocx_lib::package::version` (defined in `version/build_meta.rs`) |
4444
| `error.rs` | `MirrorError`: SpecInvalid, SpecNotFound, ExecutionFailed, SourceError |
4545

4646
## Pipeline Architecture

.claude/rules/subsystem-package.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ Tagged enum metadata (`Metadata::Bundle`) supports future format versions, no br
3535
| `bundle.rs` | `BundleBuilder`: tar archive creation with configurable compression |
3636
| `cascade.rs` | Cascade algebra: `decompose()`, `cascade()`, `resolve_cascade_tags()`, `push_with_cascade()` |
3737
| `tag.rs` | `Tag` enum: Latest, Internal(InternalTag), Version, Canonical, Other |
38-
| `version.rs` | `Version` struct: semver-inspired with build + prerelease, rolling tag support |
38+
| `version.rs` | `Version` struct: semver-inspired with build + prerelease, rolling tag support; `with_build(seg)` attaches a build-metadata segment (errors if no `X.Y.Z` core or build already present); re-exports `BuildTimestampFormat` + `build_timestamp` from `version/build_meta.rs` |
39+
| `version/build_meta.rs` | `BuildTimestampFormat` enum (`Datetime` / `Date` / `None`); `build_timestamp(format)` returns the UTC stamp string or `None`; `BuildMetaError` enum (`NoPatch` / `AlreadyPresent`) |
3940
| `install_info.rs` | `InstallInfo`: identifier + metadata + content path |
4041
| `description.rs` | Package description metadata (title, description, keywords, README, logo) |
4142

.github/actions/build-rust/action.yml

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
name: Build Rust Binary
2-
description: Build an ocx binary for a given target (native builds only)
2+
description: Build an ocx binary for a given target (native builds or zig cross-compile)
33

44
inputs:
55
target:
66
description: Rust target triple
77
required: true
8+
use_zigbuild:
9+
description: Use cargo-zigbuild instead of cargo build (required for cross-compiling aarch64-linux and musl from x86 runners)
10+
required: false
11+
default: "false"
812

913
runs:
1014
using: composite
@@ -14,15 +18,38 @@ runs:
1418
with:
1519
toolchain: stable
1620
targets: ${{ inputs.target }}
21+
- name: Install target on rust-toolchain.toml channel
22+
# rust-toolchain.toml pins an explicit channel; dtolnay's `targets:` only
23+
# adds the target to the `stable` alias toolchain. Re-add against the
24+
# pinned channel resolved from the workspace root so cargo finds std/core.
25+
shell: bash
26+
env:
27+
TARGET: ${{ inputs.target }}
28+
run: rustup target add "$TARGET"
1729
- name: Rust Cache
1830
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
1931
with:
2032
save-if: ${{ github.ref == 'refs/heads/main' }}
21-
- name: Build
33+
- name: Install Zig
34+
if: ${{ inputs.use_zigbuild == 'true' }}
35+
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
36+
- name: Install cargo-zigbuild
37+
if: ${{ inputs.use_zigbuild == 'true' }}
38+
uses: taiki-e/install-action@58e862542551f667fa44c8a2a4a1d64ad477c96a # v2.75.17
39+
with:
40+
tool: cargo-zigbuild
41+
- name: Build (cargo)
42+
if: ${{ inputs.use_zigbuild != 'true' }}
2243
shell: bash
2344
env:
2445
TARGET: ${{ inputs.target }}
2546
run: cargo build --release --target="$TARGET" --locked
47+
- name: Build (zigbuild)
48+
if: ${{ inputs.use_zigbuild == 'true' }}
49+
shell: bash
50+
env:
51+
TARGET: ${{ inputs.target }}
52+
run: cargo zigbuild --release --target="$TARGET" --locked
2653
- name: Prepare Binaries
2754
shell: bash
2855
env:

.github/workflows/deploy-dev.yml

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
name: Deploy Dev
2+
3+
# Manual deploy pipeline. Run via `gh workflow run "Deploy Dev"` or the Actions
4+
# UI. Builds all production targets (linux musl + darwin + windows) for `ocx`
5+
# and `ocx-mirror`, then publishes them to `dev.ocx.sh/ocx:<core>-dev_<TS>`
6+
# (and `mirror-<core>-dev_<TS>`).
7+
#
8+
# Musl-only linux matches the prod target set in `post-release-oci-publish.yml`.
9+
# Cross-compile uses cargo-zigbuild on ubuntu-22.04 (linux), native build on
10+
# macos-latest (darwin), and `cargo xwin` in a container on ubuntu-latest
11+
# (windows). Published artifacts are tagged as pre-release (`-dev_<TS>`) so
12+
# they cannot collide with the stable release graph.
13+
14+
on:
15+
workflow_dispatch:
16+
17+
permissions:
18+
contents: read
19+
20+
# Serialize runs so the timestamp suffix stays monotonic; never cancel a
21+
# publish in flight.
22+
concurrency:
23+
group: deploy-dev-${{ github.ref }}
24+
cancel-in-progress: false
25+
26+
jobs:
27+
compute-version:
28+
name: Compute version
29+
runs-on: ubuntu-latest
30+
outputs:
31+
version: ${{ steps.cliff.outputs.version }}
32+
version_tag: ${{ steps.cliff.outputs.version_tag }}
33+
steps:
34+
- name: Checkout
35+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
36+
with:
37+
fetch-depth: 0
38+
persist-credentials: false
39+
- name: Install git-cliff
40+
uses: taiki-e/install-action@58e862542551f667fa44c8a2a4a1d64ad477c96a # v2.75.17
41+
with:
42+
tool: git-cliff
43+
- name: Compute next version and timestamp
44+
id: cliff
45+
shell: bash
46+
run: |
47+
set -euo pipefail
48+
raw="$(git-cliff --bumped-version)"
49+
version="${raw#v}"
50+
if [[ -z "$version" ]]; then
51+
echo "::error::git-cliff --bumped-version returned empty output; no commits since last tag — refusing to publish a dev build"
52+
exit 1
53+
fi
54+
ts="$(date -u +%Y%m%d%H%M%S)"
55+
echo "version=${version}" | tee -a "$GITHUB_OUTPUT"
56+
echo "version_tag=${version}-dev_${ts}" | tee -a "$GITHUB_OUTPUT"
57+
58+
build-linux:
59+
name: Build (${{ matrix.target }})
60+
needs: [compute-version]
61+
# ubuntu-22.04 matches the runner cargo-dist plans for linux targets —
62+
# older glibc baseline for portability of any future gnu targets.
63+
runs-on: ubuntu-22.04
64+
strategy:
65+
fail-fast: false
66+
matrix:
67+
target:
68+
- x86_64-unknown-linux-musl
69+
- aarch64-unknown-linux-musl
70+
steps:
71+
- name: Checkout
72+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
73+
with:
74+
submodules: true
75+
persist-credentials: false
76+
- name: Build
77+
uses: ./.github/actions/build-rust
78+
with:
79+
target: ${{ matrix.target }}
80+
# cargo-zigbuild covers aarch64 and musl targets uniformly from
81+
# x86_64 ubuntu runners; matches what cargo-dist uses for prod.
82+
use_zigbuild: true
83+
84+
build-darwin:
85+
name: Build (${{ matrix.target }})
86+
needs: [compute-version]
87+
runs-on: macos-latest
88+
strategy:
89+
fail-fast: false
90+
matrix:
91+
target:
92+
- x86_64-apple-darwin
93+
- aarch64-apple-darwin
94+
steps:
95+
- name: Checkout
96+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
97+
with:
98+
submodules: true
99+
persist-credentials: false
100+
- name: Build
101+
uses: ./.github/actions/build-rust
102+
with:
103+
target: ${{ matrix.target }}
104+
105+
build-windows:
106+
name: Build (${{ matrix.target }})
107+
needs: [compute-version]
108+
runs-on: ubuntu-latest
109+
container:
110+
# messense/cargo-xwin has no stable versioned tag — pinned by digest. Re-pin with:
111+
# docker pull messense/cargo-xwin && docker inspect --format='{{index .RepoDigests 0}}' messense/cargo-xwin
112+
image: messense/cargo-xwin@sha256:543cdaeb5cd3ed0ca1dd29b9e5aba4100aa0008f8ed088b793f3b9255a635869
113+
strategy:
114+
fail-fast: false
115+
matrix:
116+
target:
117+
- x86_64-pc-windows-msvc
118+
- aarch64-pc-windows-msvc
119+
steps:
120+
- name: Checkout
121+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
122+
with:
123+
submodules: true
124+
persist-credentials: false
125+
- name: Install Rust target
126+
shell: bash
127+
run: rustup target add ${{ matrix.target }}
128+
- name: Build
129+
shell: bash
130+
run: cargo xwin build --release --target=${{ matrix.target }} --locked
131+
- name: Prepare Binaries
132+
shell: bash
133+
run: |
134+
cp target/${{ matrix.target }}/release/ocx.exe ocx-${{ matrix.target }}.exe
135+
cp target/${{ matrix.target }}/release/ocx-mirror.exe ocx-mirror-${{ matrix.target }}.exe
136+
- name: Upload ocx Binary
137+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
138+
with:
139+
name: ocx-${{ matrix.target }}
140+
path: ocx-${{ matrix.target }}.exe
141+
compression-level: 0
142+
retention-days: 1
143+
- name: Upload ocx-mirror Binary
144+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
145+
with:
146+
name: ocx-mirror-${{ matrix.target }}
147+
path: ocx-mirror-${{ matrix.target }}.exe
148+
compression-level: 0
149+
retention-days: 1
150+
151+
publish:
152+
name: Publish to dev.ocx.sh
153+
needs: [compute-version, build-linux, build-darwin, build-windows]
154+
uses: ./.github/workflows/oci-publish.yml
155+
with:
156+
registry: dev.ocx.sh
157+
repo: ocx
158+
version_tag: ${{ needs.compute-version.outputs.version_tag }}
159+
variants: '["","mirror"]'
160+
targets: '["x86_64-unknown-linux-musl","aarch64-unknown-linux-musl","x86_64-apple-darwin","aarch64-apple-darwin","x86_64-pc-windows-msvc","aarch64-pc-windows-msvc"]'
161+
environment: dev.ocx.sh
162+
secrets:
163+
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
164+
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}

.github/workflows/notify-discord.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ on:
88
types: [published]
99

1010
workflow_run:
11-
workflows: ["Basic Verification", "License Compliance", "Deploy Canary", "Deploy Website"]
11+
workflows: ["Basic Verification", "License Compliance", "Deploy Canary", "Deploy Website", "Deploy Dev"]
1212
types: [completed]
1313

1414
deployment_status:

0 commit comments

Comments
 (0)