Merge pull request #12 from hienao/dev #9
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Publish image | |
| on: | |
| push: | |
| tags: ['v*'] | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: Existing version tag to publish (for example v1.2.0-beta.1 or v1.2.0) | |
| required: true | |
| type: string | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: publish-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} | |
| cancel-in-progress: false | |
| jobs: | |
| classify: | |
| name: Validate release tag | |
| runs-on: ubuntu-latest | |
| outputs: | |
| channel: ${{ steps.version.outputs.channel }} | |
| environment: ${{ steps.version.outputs.environment }} | |
| tag: ${{ steps.version.outputs.tag }} | |
| version: ${{ steps.version.outputs.version }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} | |
| fetch-depth: 0 | |
| - name: Classify and validate version | |
| id: version | |
| shell: bash | |
| env: | |
| REQUESTED_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} | |
| run: | | |
| set -euo pipefail | |
| tag="${REQUESTED_TAG}" | |
| if [[ "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then | |
| channel="beta" | |
| environment="ghcr-beta" | |
| elif [[ "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| channel="production" | |
| environment="ghcr-production" | |
| else | |
| echo "Unsupported release tag: ${tag}" >&2 | |
| exit 1 | |
| fi | |
| git fetch origin main:refs/remotes/origin/main --tags --force | |
| tag_sha="$(git rev-parse "${tag}^{commit}")" | |
| if ! git merge-base --is-ancestor "${tag_sha}" origin/main; then | |
| echo "Release tag ${tag} does not point to a commit contained in main" >&2 | |
| exit 1 | |
| fi | |
| echo "channel=${channel}" >> "${GITHUB_OUTPUT}" | |
| echo "environment=${environment}" >> "${GITHUB_OUTPUT}" | |
| echo "tag=${tag}" >> "${GITHUB_OUTPUT}" | |
| echo "version=${tag#v}" >> "${GITHUB_OUTPUT}" | |
| test: | |
| name: Release tests | |
| needs: classify | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ needs.classify.outputs.tag }} | |
| - uses: actions/setup-go@v7 | |
| with: | |
| go-version-file: backend/go.mod | |
| cache-dependency-path: backend/go.sum | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| cache-dependency-path: frontend/package-lock.json | |
| - name: Backend tests | |
| working-directory: backend | |
| run: go test -race ./... | |
| - name: Backend static analysis | |
| working-directory: backend | |
| run: go vet ./... | |
| - name: Frontend install | |
| working-directory: frontend | |
| run: npm ci | |
| - name: Frontend tests | |
| working-directory: frontend | |
| run: npm test -- --run | |
| - name: Frontend build | |
| working-directory: frontend | |
| run: npm run build | |
| - name: Validate Compose | |
| run: docker compose --env-file .env.example config | |
| verify-image: | |
| name: Container smoke test | |
| needs: classify | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ needs.classify.outputs.tag }} | |
| - uses: docker/setup-buildx-action@v4 | |
| - name: Build verification image | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| file: deploy/Dockerfile | |
| platforms: linux/amd64 | |
| load: true | |
| push: false | |
| tags: oscraper:release-check | |
| cache-from: type=gha,scope=oscraper-linux-amd64 | |
| cache-to: type=gha,mode=min,scope=oscraper-linux-amd64,ignore-error=true | |
| - name: Verify container health | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| host_uid="$(id -u)" | |
| host_gid="$(id -g)" | |
| data_dir="${RUNNER_TEMP}/oscraper-release-data-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" | |
| cache_dir="${RUNNER_TEMP}/oscraper-release-cache-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" | |
| media_dir="${RUNNER_TEMP}/oscraper-release-media-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" | |
| mkdir -p "${data_dir}" "${cache_dir}" "${media_dir}" | |
| media_owner_before="$(stat -c '%u:%g' "${media_dir}")" | |
| cleanup() { | |
| docker rm --force oscraper-release-check >/dev/null 2>&1 || true | |
| } | |
| trap cleanup EXIT | |
| docker run --detach --name oscraper-release-check \ | |
| --env APP_ENV=production \ | |
| --env JWT_SECRET=release-check-jwt-secret-with-at-least-32-characters \ | |
| --env CREDENTIAL_ENCRYPTION_KEY=abcdef0123456789abcdef0123456789 \ | |
| --env PUID="${host_uid}" \ | |
| --env PGID="${host_gid}" \ | |
| --env UMASK=027 \ | |
| --volume "${data_dir}:/data" \ | |
| --volume "${cache_dir}:/cache" \ | |
| --volume "${media_dir}:/media" \ | |
| oscraper:release-check >/dev/null | |
| for attempt in $(seq 1 30); do | |
| if response="$(docker exec oscraper-release-check wget -qO- http://127.0.0.1:3113/api/health 2>/dev/null)"; then | |
| echo "${response}" | |
| echo "${response}" | grep -q '"status":"ok"' | |
| docker exec oscraper-release-check sh -eu -c ' | |
| awk -v expected="$PUID" '\''$1 == "Uid:" { exit !($2 == expected) }'\'' /proc/1/status | |
| awk -v expected="$PGID" '\''$1 == "Gid:" { exit !($2 == expected) }'\'' /proc/1/status | |
| grep -Eq "^Umask:[[:space:]]+0027$" /proc/1/status | |
| test "$(stat -c "%u:%g" /data/db)" = "$PUID:$PGID" | |
| test "$(stat -c "%u:%g" /cache/logs)" = "$PUID:$PGID" | |
| ' | |
| test "$(stat -c '%u:%g' "${media_dir}")" = "${media_owner_before}" | |
| exit 0 | |
| fi | |
| if [[ "$(docker inspect --format '{{.State.Running}}' oscraper-release-check)" != "true" ]]; then | |
| docker logs oscraper-release-check | |
| exit 1 | |
| fi | |
| sleep 1 | |
| done | |
| docker logs oscraper-release-check | |
| exit 1 | |
| build-platform: | |
| name: Build ${{ matrix.platform }} image | |
| needs: [classify, test, verify-image] | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - platform: linux/amd64 | |
| runner: ubuntu-24.04 | |
| artifact: amd64 | |
| - platform: linux/arm64 | |
| runner: ubuntu-24.04-arm | |
| artifact: arm64 | |
| runs-on: ${{ matrix.runner }} | |
| environment: ${{ needs.classify.outputs.environment }} | |
| permissions: | |
| contents: read | |
| packages: write | |
| env: | |
| IMAGE: ghcr.io/${{ github.repository_owner }}/oscraper | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ needs.classify.outputs.tag }} | |
| - uses: docker/setup-buildx-action@v4 | |
| - name: Log in to GHCR | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Protect immutable version tag | |
| shell: bash | |
| env: | |
| VERSION: ${{ needs.classify.outputs.version }} | |
| run: | | |
| if docker buildx imagetools inspect "${IMAGE}:${VERSION}" >/dev/null 2>&1; then | |
| echo "Image ${IMAGE}:${VERSION} already exists and will not be overwritten" >&2 | |
| exit 1 | |
| fi | |
| - name: Generate image metadata | |
| uses: docker/metadata-action@v6 | |
| id: meta | |
| with: | |
| images: ${{ env.IMAGE }} | |
| flavor: | | |
| latest=false | |
| tags: | | |
| type=raw,value=${{ needs.classify.outputs.version }} | |
| type=raw,value=beta,enable=${{ needs.classify.outputs.channel == 'beta' }} | |
| type=semver,pattern={{major}}.{{minor}},value=${{ needs.classify.outputs.tag }},enable=${{ needs.classify.outputs.channel == 'production' }} | |
| type=semver,pattern={{major}},value=${{ needs.classify.outputs.tag }},enable=${{ needs.classify.outputs.channel == 'production' }} | |
| type=raw,value=latest,enable=${{ needs.classify.outputs.channel == 'production' }} | |
| type=sha,prefix=sha-,format=long | |
| - name: Build and push platform image by digest | |
| uses: docker/build-push-action@v7 | |
| id: build | |
| with: | |
| context: . | |
| file: deploy/Dockerfile | |
| platforms: ${{ matrix.platform }} | |
| outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true | |
| labels: ${{ steps.meta.outputs.labels }} | |
| cache-from: type=gha,scope=oscraper-${{ matrix.artifact }} | |
| cache-to: type=gha,mode=min,scope=oscraper-${{ matrix.artifact }},ignore-error=true | |
| provenance: mode=max | |
| sbom: true | |
| - name: Export platform digest | |
| shell: bash | |
| env: | |
| DIGEST: ${{ steps.build.outputs.digest }} | |
| run: | | |
| set -euo pipefail | |
| if [[ ! "${DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then | |
| echo "Invalid platform digest: ${DIGEST}" >&2 | |
| exit 1 | |
| fi | |
| mkdir -p "${RUNNER_TEMP}/digests" | |
| touch "${RUNNER_TEMP}/digests/${DIGEST#sha256:}" | |
| - name: Upload platform digest | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: digests-${{ matrix.artifact }} | |
| path: ${{ runner.temp }}/digests/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| publish: | |
| name: Publish ${{ needs.classify.outputs.channel }} manifest | |
| needs: [classify, build-platform] | |
| runs-on: ubuntu-24.04 | |
| environment: ${{ needs.classify.outputs.environment }} | |
| permissions: | |
| contents: read | |
| packages: write | |
| env: | |
| IMAGE: ghcr.io/${{ github.repository_owner }}/oscraper | |
| steps: | |
| - uses: docker/setup-buildx-action@v4 | |
| - name: Log in to GHCR | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Protect immutable version tag | |
| shell: bash | |
| env: | |
| VERSION: ${{ needs.classify.outputs.version }} | |
| run: | | |
| if docker buildx imagetools inspect "${IMAGE}:${VERSION}" >/dev/null 2>&1; then | |
| echo "Image ${IMAGE}:${VERSION} already exists and will not be overwritten" >&2 | |
| exit 1 | |
| fi | |
| - name: Download platform digests | |
| uses: actions/download-artifact@v8 | |
| with: | |
| pattern: digests-* | |
| path: ${{ runner.temp }}/digests | |
| merge-multiple: true | |
| - name: Generate image metadata | |
| uses: docker/metadata-action@v6 | |
| id: meta | |
| with: | |
| images: ${{ env.IMAGE }} | |
| flavor: | | |
| latest=false | |
| tags: | | |
| type=raw,value=${{ needs.classify.outputs.version }} | |
| type=raw,value=beta,enable=${{ needs.classify.outputs.channel == 'beta' }} | |
| type=semver,pattern={{major}}.{{minor}},value=${{ needs.classify.outputs.tag }},enable=${{ needs.classify.outputs.channel == 'production' }} | |
| type=semver,pattern={{major}},value=${{ needs.classify.outputs.tag }},enable=${{ needs.classify.outputs.channel == 'production' }} | |
| type=raw,value=latest,enable=${{ needs.classify.outputs.channel == 'production' }} | |
| type=sha,prefix=sha-,format=long | |
| - name: Create multi-platform manifest | |
| shell: bash | |
| env: | |
| TAGS: ${{ steps.meta.outputs.tags }} | |
| run: | | |
| set -euo pipefail | |
| tag_args=() | |
| while IFS= read -r tag; do | |
| if [[ -n "${tag}" ]]; then | |
| tag_args+=(--tag "${tag}") | |
| fi | |
| done <<< "${TAGS}" | |
| digest_args=() | |
| for digest_file in "${RUNNER_TEMP}"/digests/*; do | |
| if [[ ! -f "${digest_file}" || ! "$(basename "${digest_file}")" =~ ^[0-9a-f]{64}$ ]]; then | |
| echo "Invalid digest artifact: ${digest_file}" >&2 | |
| exit 1 | |
| fi | |
| digest_args+=("${IMAGE}@sha256:$(basename "${digest_file}")") | |
| done | |
| if [[ "${#digest_args[@]}" -ne 2 ]]; then | |
| echo "Expected two platform digests, found ${#digest_args[@]}" >&2 | |
| exit 1 | |
| fi | |
| docker buildx imagetools create "${tag_args[@]}" "${digest_args[@]}" | |
| - name: Verify published manifest | |
| id: manifest | |
| shell: bash | |
| env: | |
| VERSION: ${{ needs.classify.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| manifest="$(docker buildx imagetools inspect "${IMAGE}:${VERSION}")" | |
| echo "${manifest}" | |
| grep -Eq 'Platform:[[:space:]]+linux/amd64' <<< "${manifest}" | |
| grep -Eq 'Platform:[[:space:]]+linux/arm64' <<< "${manifest}" | |
| digest="$(awk '$1 == "Digest:" { print $2; exit }' <<< "${manifest}")" | |
| if [[ ! "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then | |
| echo "Invalid published manifest digest: ${digest}" >&2 | |
| exit 1 | |
| fi | |
| echo "digest=${digest}" >> "${GITHUB_OUTPUT}" | |
| - name: Write release summary | |
| shell: bash | |
| env: | |
| CHANNEL: ${{ needs.classify.outputs.channel }} | |
| DIGEST: ${{ steps.manifest.outputs.digest }} | |
| VERSION: ${{ needs.classify.outputs.version }} | |
| run: | | |
| { | |
| echo "## OScraper image published" | |
| echo | |
| echo "- Channel: \`${CHANNEL}\`" | |
| echo "- Image: \`${IMAGE}:${VERSION}\`" | |
| echo "- Digest: \`${DIGEST}\`" | |
| } >> "${GITHUB_STEP_SUMMARY}" |