Compare commits

..
Author SHA1 Message Date
sechmachine d044277194 docs(test): record Gitea runner verification
Container / amd64 (push) Failing after 6m24s
Container / manifest (push) Skipped
Container / verify (push) Successful in 3m12s
Container / arm64 (push) Skipped
Verify / verify (push) Successful in 8m59s
2026-08-15 23:55:52 +07:00
sechmachine d13443e338 fix(ci): gate images behind verified Testcontainers
Verify / verify (push) Successful in 8m52s
2026-08-15 23:21:23 +07:00
7 changed files with 235 additions and 30 deletions
+78 -17
View File
@@ -1,8 +1,10 @@
name: Container name: Container
'on': 'on':
pull_request: workflow_dispatch:
push: push:
branches:
- main
permissions: permissions:
contents: read contents: read
@@ -12,17 +14,66 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
TESTCONTAINERS_HOST_OVERRIDE: host.docker.internal
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Java 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: '25'
cache: maven
- name: Set up Node 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: npm
- name: Verify Docker for PostgreSQL tests
run: docker info
- name: Install frontend dependencies
run: npm ci
- name: Run frontend tests
run: npm run test:ui
- name: Build frontend assets
run: npm run build
- name: Verify generated assets are committed
run: git diff --exit-code -- src/main/resources/static/assets/app.css src/main/resources/static/assets/icons.svg
- name: Run Maven tests
run: ./mvnw -B test
- name: Verify Javadoc
run: ./mvnw -B -DskipTests -Ddoclint=all javadoc:javadoc
- name: Verify whitespace
run: git diff --check
amd64: amd64:
needs: verify
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- name: Check out source - name: Check out source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Buildx - name: Set up Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Select image tags - name: Select image tags
id: image id: image
@@ -54,14 +105,14 @@ jobs:
- name: Log in to registry - name: Log in to registry
if: steps.image.outputs.publish == 'true' if: steps.image.outputs.publish == 'true'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: ${{ steps.image.outputs.registry }} registry: ${{ steps.image.outputs.registry }}
username: ${{ secrets.REGISTRY_USERNAME }} username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }} password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build AMD64 image and publish main - name: Build AMD64 image and publish main
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with: with:
context: . context: .
platforms: linux/amd64 platforms: linux/amd64
@@ -73,42 +124,52 @@ jobs:
arm64: arm64:
# Gitea cannot schedule a probe on a missing label. Enable this repository variable only # Gitea cannot schedule a probe on a missing label. Enable this repository variable only
# while a trusted ubuntu-latest-arm runner is registered and online. # while a trusted ubuntu-latest-arm runner is registered and online.
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' && vars.ARM64_RUNNER_AVAILABLE == 'true' if: vars.ARM64_RUNNER_AVAILABLE == 'true'
needs: verify
runs-on: ubuntu-latest-arm runs-on: ubuntu-latest-arm
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- name: Check out source - name: Check out source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Buildx - name: Set up Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Require image configuration - name: Select image tag
id: image id: image
env: env:
CONFIGURED_IMAGE: ${{ vars.CONTAINER_IMAGE }} CONFIGURED_IMAGE: ${{ vars.CONTAINER_IMAGE }}
EVENT_NAME: ${{ gitea.event_name }}
GITEA_REF: ${{ gitea.ref }}
GITEA_SHA: ${{ gitea.sha }} GITEA_SHA: ${{ gitea.sha }}
run: | run: |
publish=false
image=labtimesheet
if [ "$EVENT_NAME" = "push" ] && [ "$GITEA_REF" = "refs/heads/main" ]; then
test -n "$CONFIGURED_IMAGE" || { echo "Repository variable CONTAINER_IMAGE is required" >&2; exit 1; } test -n "$CONFIGURED_IMAGE" || { echo "Repository variable CONTAINER_IMAGE is required" >&2; exit 1; }
echo "image=$CONFIGURED_IMAGE" >> "$GITHUB_OUTPUT" publish=true
echo "registry=${CONFIGURED_IMAGE%%/*}" >> "$GITHUB_OUTPUT" image="$CONFIGURED_IMAGE"
echo "tag=$CONFIGURED_IMAGE:sha-${GITEA_SHA}-arm64" >> "$GITHUB_OUTPUT" fi
echo "publish=$publish" >> "$GITHUB_OUTPUT"
echo "registry=${image%%/*}" >> "$GITHUB_OUTPUT"
echo "tag=$image:sha-${GITEA_SHA}-arm64" >> "$GITHUB_OUTPUT"
- name: Log in to registry - name: Log in to registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 if: steps.image.outputs.publish == 'true'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: ${{ steps.image.outputs.registry }} registry: ${{ steps.image.outputs.registry }}
username: ${{ secrets.REGISTRY_USERNAME }} username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }} password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build native ARM64 image - name: Build native ARM64 image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with: with:
context: . context: .
platforms: linux/arm64 platforms: linux/arm64
push: true push: ${{ steps.image.outputs.publish }}
tags: ${{ steps.image.outputs.tag }} tags: ${{ steps.image.outputs.tag }}
build-args: | build-args: |
VCS_REF=${{ gitea.sha }} VCS_REF=${{ gitea.sha }}
@@ -120,7 +181,7 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: Set up Buildx - name: Set up Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Resolve registry - name: Resolve registry
id: image id: image
@@ -131,7 +192,7 @@ jobs:
echo "registry=${CONFIGURED_IMAGE%%/*}" >> "$GITHUB_OUTPUT" echo "registry=${CONFIGURED_IMAGE%%/*}" >> "$GITHUB_OUTPUT"
- name: Log in to registry - name: Log in to registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: ${{ steps.image.outputs.registry }} registry: ${{ steps.image.outputs.registry }}
username: ${{ secrets.REGISTRY_USERNAME }} username: ${{ secrets.REGISTRY_USERNAME }}
+5 -3
View File
@@ -15,21 +15,23 @@ jobs:
verify: verify:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
env:
TESTCONTAINERS_HOST_OVERRIDE: host.docker.internal
steps: steps:
- name: Check out source - name: Check out source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Java 25 - name: Set up Java 25
uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: temurin distribution: temurin
java-version: '25' java-version: '25'
cache: maven cache: maven
- name: Set up Node 24 - name: Set up Node 24
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: '24' node-version: '24'
cache: npm cache: npm
+1 -1
View File
@@ -63,7 +63,7 @@ Configure these repository settings:
| Secret | `REGISTRY_USERNAME` | Registry user allowed to publish this package | | Secret | `REGISTRY_USERNAME` | Registry user allowed to publish this package |
| Secret | `REGISTRY_TOKEN` | Registry token with package write access | | Secret | `REGISTRY_TOKEN` | Registry token with package write access |
`verify.yml` runs for every pull request and push. `container.yml` builds AMD64 for every pull request and push, but publishes only a push to `main`. Main always receives immutable `sha-<commit>` and convenience `main` tags. `verify.yml` runs for every pull request and push. `container.yml` runs only when manually dispatched or when `main` is pushed, and it repeats verification before either architecture build. Manual runs build without publishing. A push to `main` publishes immutable `sha-<commit>` and convenience `main` tags.
When ARM64 is disabled, those canonical tags remain valid AMD64 images and the workflow succeeds. When it is enabled, the native ARM runner publishes an architecture tag and the final job replaces the canonical tags with a combined AMD64/ARM64 manifest. Gitea cannot discover an unavailable runner from inside an unscheduled job, so the repository variable is the deliberate availability gate. When ARM64 is disabled, those canonical tags remain valid AMD64 images and the workflow succeeds. When it is enabled, the native ARM runner publishes an architecture tag and the final job replaces the canonical tags with a combined AMD64/ARM64 manifest. Gitea cannot discover an unavailable runner from inside an unscheduled job, so the repository variable is the deliberate availability gate.
+6 -5
View File
@@ -118,11 +118,12 @@ companion record under [`docs/tests`](docs/tests/README.md).
## Continuous integration and production containers ## Continuous integration and production containers
Gitea Actions now verifies every pull request and push. A separate container Gitea Actions verifies every pull request and push. The separate container
workflow builds Linux AMD64 and publishes only from `main`; native Linux ARM64 workflow runs only for a manual dispatch or a push to `main`, and its verification
is added only when the repository explicitly declares that its ARM runner is job must pass before either image build starts. Manual runs build without publishing;
online. Every published revision has an immutable `sha-<full-commit>` tag, with `main` pushes publish Linux AMD64 and add native Linux ARM64 only when the repository
`main` as a convenience alias. explicitly declares that its ARM runner is online. Every published revision has an
immutable `sha-<full-commit>` tag, with `main` as a convenience alias.
The production image is a non-root Java 25 image. The root [compose.yaml](compose.yaml) The production image is a non-root Java 25 image. The root [compose.yaml](compose.yaml)
supports either a persistent PostgreSQL 18.4 sidecar or an external PostgreSQL supports either a persistent PostgreSQL 18.4 sidecar or an external PostgreSQL
+7 -2
View File
@@ -223,8 +223,9 @@ claim never replaces a test command and result.
Gitea runs the frontend tests/build, complete Maven/PostgreSQL suite, Javadoc, Gitea runs the frontend tests/build, complete Maven/PostgreSQL suite, Javadoc,
generated-asset check, and whitespace check for every pull request and push. generated-asset check, and whitespace check for every pull request and push.
The separate container workflow builds the production Dockerfile without The separate container workflow runs only when manually dispatched or when
publishing pull-request or work-branch images. Only `main` publishes. `main` is pushed. It repeats the verification job before building either image.
Manual runs do not publish; only a push to `main` publishes.
Run focused and affected tests locally before pushing. CI is the shared Run focused and affected tests locally before pushing. CI is the shared
confirmation, not a substitute for local RED and GREEN evidence. confirmation, not a substitute for local RED and GREEN evidence.
@@ -236,6 +237,10 @@ confirmation, not a substitute for local RED and GREEN evidence.
Start Docker Desktop or OrbStack. Run `docker version`. OrbStack users should Start Docker Desktop or OrbStack. Run `docker version`. OrbStack users should
also check the `DOCKER_HOST` command shown in Section 1. also check the `DOCKER_HOST` command shown in Section 1.
The Gitea Docker runner exposes the daemon through Docker Desktop, so its jobs
set `TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal`. Keep that override if
the runner stays containerized; otherwise Ryuk may try an unreachable bridge IP.
### The wrong Java version is used ### The wrong Java version is used
Run `java -version` and `./mvnw -version`. Both should report Java 25. Set Run `java -version` and `./mvnw -version`. Both should report Java 25. Set
@@ -0,0 +1,106 @@
# Test Evidence: Gitea Testcontainers and container workflow gates
- **Test type:** Integration
- **Requirement IDs:** `OPS-011`, `OPS-012`, `TST-001`, `TST-005`, `TST-009`
- **Scenario IDs:** `AC-OPS-002`, `AC-OPS-004`
- **Test class/method:** `src/test/js/delivery-contract.test.mjs`
- **Implementation commit:** `d13443e338770dec0ca9822600f9a9d8405dfdbb`
## Protected behavior
Gitea verification must reach Docker Desktop-published Testcontainers ports from
inside its job container. Container builds may start only after an equivalent
verification job succeeds, and the container workflow may run only by manual
dispatch or by a push to `main`. Every third-party workflow action is pinned to
the reviewed latest release commit rather than a moving tag.
## Test method
The dependency-free delivery contract reads both workflow files and checks the
Testcontainers host override, event filters, verify-to-build dependencies, and
the complete allowlist of immutable action SHAs. The remote failure log supplies
the production-shaped network reproduction because it ran inside the real Gitea
Docker runner.
## Hand-derived expected result
The runner already resolves `host.docker.internal` to its Docker host. Therefore
Testcontainers must use that host instead of the job-network gateway
`172.17.0.1`. Pull requests and non-main branch pushes must never schedule the
container workflow. Manual dispatches build but do not publish, while main pushes
publish only after verification succeeds.
## RED
**Command**
```text
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin \
node --test src/test/js/delivery-contract.test.mjs
tea actions runs logs 174 --repo sechmachine/labtimesheet \
--login sechmachine-git
```
**Observed result**
```text
Delivery contract: 4 tests, 1 passed, 3 failed. The workflows lacked the
Testcontainers host override, container event/dependency gates, and current
action pins.
Gitea run 174 found Docker at unix:///var/run/docker.sock but selected host
172.17.0.1. Ryuk started, then repeated connections to 172.17.0.1:57499 were
refused. Maven ended with 217 tests, 64 errors.
```
## GREEN
**Command**
```text
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin \
node --test src/test/js/delivery-contract.test.mjs
```
**Observed result**
```text
Delivery contract: 4 tests, 4 passed.
```
## Affected suite
**Command and result**
```text
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin npm run test:ui
Result: 5 tests passed.
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 \
PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin \
DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock \
./mvnw -B test
Result: 205 tests passed across 44 suites; 0 failures, errors, or skips.
npm ci && npm run build
Result: Tailwind and Lucide assets built successfully; tracked assets remained unchanged.
./mvnw -B -DskipTests -Ddoclint=all javadoc:javadoc
Result: BUILD SUCCESS with 83 existing missing-comment warnings and no production Java change.
Ruby YAML parsing and git diff --check
Result: both workflow files parsed and the diff check passed.
Gitea Actions run 177 on `work/fix/platform/ci-testcontainers-actions`
Result: Verify completed successfully in 8 minutes on the real Docker-mode runner.
The non-main branch push scheduled `verify.yml` only; `container.yml` did not run.
```
## External-test boundaries
The local contract cannot prove action-runner compatibility, registry credentials,
or availability of the optional ARM runner. Those are checked by the actual Gitea
branch verification and main container runs. Release freshness was checked against
the official upstream release APIs on 2026-08-15; the immutable pins remain stable,
but a later release requires an intentional reviewed update.
+31 -1
View File
@@ -6,6 +6,14 @@ import test from "node:test";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const read = (path) => readFileSync(resolve(root, path), "utf8"); const read = (path) => readFileSync(resolve(root, path), "utf8");
const expectedActionPins = new Map([
["actions/checkout", "3d3c42e5aac5ba805825da76410c181273ba90b1"],
["actions/setup-java", "b6effb05e454b25005698d916606bdc6ffcbf961"],
["actions/setup-node", "820762786026740c76f36085b0efc47a31fe5020"],
["docker/setup-buildx-action", "bb05f3f5519dd87d3ba754cc423b652a5edd6d2c"],
["docker/login-action", "dbcb813823bdd20940b903addbd779551569679f"],
["docker/build-push-action", "53b7df96c91f9c12dcc8a07bcb9ccacbed38856a"],
]);
test("production image and Compose keep one image usable with bundled or external PostgreSQL", () => { test("production image and Compose keep one image usable with bundled or external PostgreSQL", () => {
const dockerfile = read("Dockerfile"); const dockerfile = read("Dockerfile");
@@ -37,12 +45,19 @@ test("verification workflow checks every pull request and pushed branch without
assert.match(workflow, /npm run test:ui/); assert.match(workflow, /npm run test:ui/);
assert.match(workflow, /npm run build/); assert.match(workflow, /npm run build/);
assert.match(workflow, /\.\/mvnw -B test/); assert.match(workflow, /\.\/mvnw -B test/);
assert.match(workflow, /TESTCONTAINERS_HOST_OVERRIDE: host\.docker\.internal/);
assert.doesNotMatch(workflow, /permissions:\s*write-all/); assert.doesNotMatch(workflow, /permissions:\s*write-all/);
}); });
test("container workflow publishes only main and makes native ARM64 explicitly optional", () => { test("container workflow runs only manually or on main and verifies before either image build", () => {
const workflow = read(".gitea/workflows/container.yml"); const workflow = read(".gitea/workflows/container.yml");
assert.match(workflow, /'on':\n workflow_dispatch:\n push:\n branches:\n - main/);
assert.doesNotMatch(workflow, /^ pull_request:/m);
assert.match(workflow, /jobs:\n verify:/);
assert.match(workflow, /amd64:\n needs: verify/);
assert.match(workflow, /arm64:[\s\S]*?needs: verify/);
assert.match(workflow, /TESTCONTAINERS_HOST_OVERRIDE: host\.docker\.internal/);
assert.match(workflow, /runs-on: ubuntu-latest-arm/); assert.match(workflow, /runs-on: ubuntu-latest-arm/);
assert.match(workflow, /vars\.ARM64_RUNNER_AVAILABLE == 'true'/); assert.match(workflow, /vars\.ARM64_RUNNER_AVAILABLE == 'true'/);
assert.match(workflow, /gitea\.ref == 'refs\/heads\/main'/); assert.match(workflow, /gitea\.ref == 'refs\/heads\/main'/);
@@ -51,3 +66,18 @@ test("container workflow publishes only main and makes native ARM64 explicitly o
assert.match(workflow, /imagetools create/); assert.match(workflow, /imagetools create/);
assert.doesNotMatch(workflow, /ssh|DEPLOY_HOST|DEPLOY_KEY/i); assert.doesNotMatch(workflow, /ssh|DEPLOY_HOST|DEPLOY_KEY/i);
}); });
test("workflows pin every action to the latest reviewed immutable release", () => {
const workflows = [
read(".gitea/workflows/verify.yml"),
read(".gitea/workflows/container.yml"),
].join("\n");
const uses = [...workflows.matchAll(/uses:\s+([^@\s]+)@([0-9a-f]{40})/g)];
assert.ok(uses.length > 0);
for (const [, action, pin] of uses) {
assert.equal(pin, expectedActionPins.get(action), `unexpected pin for ${action}`);
}
assert.deepEqual(new Set(uses.map(([, action]) => action)), new Set(expectedActionPins.keys()));
assert.doesNotMatch(workflows, /uses:\s+[^\s]+@v\d/);
});