mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-09-26 01:12:43 -05:00
Compare commits
7 Commits
fix-codeco
...
fix-chore-
Author | SHA1 | Date | |
---|---|---|---|
![]() |
770fb2d60e | ||
![]() |
c8ef9e663a | ||
![]() |
2195e4af45 | ||
![]() |
c6716905a4 | ||
![]() |
850ee5a415 | ||
![]() |
b25b5abdb0 | ||
![]() |
68e0559053 |
430
.github/workflows/build-and-release.yml
vendored
Normal file
430
.github/workflows/build-and-release.yml
vendored
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
name: 'Build and Release'
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows:
|
||||||
|
- ci
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
pull-requests: write
|
||||||
|
env:
|
||||||
|
DEFAULT_UV_VERSION: "0.8.x"
|
||||||
|
DEFAULT_PYTHON_VERSION: "3.11"
|
||||||
|
NLTK_DATA: "/usr/share/nltk_data"
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
if: >-
|
||||||
|
github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
|
||||||
|
name: Prepare build context
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
outputs:
|
||||||
|
should-build: ${{ steps.determine.outputs.should-build }}
|
||||||
|
ref: ${{ steps.determine.outputs.ref }}
|
||||||
|
ref-name: ${{ steps.determine.outputs.ref-name }}
|
||||||
|
sha: ${{ steps.determine.outputs.sha }}
|
||||||
|
is-tag: ${{ steps.determine.outputs.is-tag }}
|
||||||
|
is-release-target: ${{ steps.determine.outputs.is-release-target }}
|
||||||
|
is-beta-rc: ${{ steps.determine.outputs.is-beta-rc }}
|
||||||
|
steps:
|
||||||
|
- name: Determine ref information
|
||||||
|
id: determine
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const run = context.payload.workflow_run;
|
||||||
|
const owner = context.repo.owner;
|
||||||
|
const repo = context.repo.repo;
|
||||||
|
const sha = run.head_sha;
|
||||||
|
const branch = run.head_branch;
|
||||||
|
|
||||||
|
let ref = undefined;
|
||||||
|
let refName = undefined;
|
||||||
|
|
||||||
|
if (branch) {
|
||||||
|
ref = `refs/heads/${branch}`;
|
||||||
|
refName = branch;
|
||||||
|
} else {
|
||||||
|
const iterator = github.paginate.iterator(
|
||||||
|
github.rest.repos.listTags,
|
||||||
|
{
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
per_page: 100,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for await (const { data } of iterator) {
|
||||||
|
const match = data.find((tag) => tag.commit?.sha === sha);
|
||||||
|
if (match) {
|
||||||
|
ref = `refs/tags/${match.name}`;
|
||||||
|
refName = match.name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputs = {
|
||||||
|
shouldBuild: false,
|
||||||
|
ref: ref ?? '',
|
||||||
|
refName: refName ?? '',
|
||||||
|
sha,
|
||||||
|
isTag: ref?.startsWith('refs/tags/') ?? false,
|
||||||
|
isReleaseTarget: false,
|
||||||
|
isBetaRc: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!ref || !refName) {
|
||||||
|
core.info('No matching ref found for workflow run; skipping post-CI workflow.');
|
||||||
|
} else {
|
||||||
|
const allowed =
|
||||||
|
ref.startsWith('refs/heads/feature-') ||
|
||||||
|
ref.startsWith('refs/heads/fix-') ||
|
||||||
|
ref.startsWith('refs/heads/l10n_') ||
|
||||||
|
ref === 'refs/heads/dev' ||
|
||||||
|
ref === 'refs/heads/beta' ||
|
||||||
|
ref.includes('beta.rc') ||
|
||||||
|
ref.startsWith('refs/tags/v');
|
||||||
|
|
||||||
|
const isBetaRc = refName.includes('beta.rc');
|
||||||
|
const isReleaseTarget = outputs.isTag && (refName.startsWith('v') || isBetaRc);
|
||||||
|
|
||||||
|
outputs.shouldBuild = allowed;
|
||||||
|
outputs.isReleaseTarget = isReleaseTarget;
|
||||||
|
outputs.isBetaRc = isBetaRc;
|
||||||
|
}
|
||||||
|
|
||||||
|
core.setOutput('should-build', outputs.shouldBuild ? 'true' : 'false');
|
||||||
|
core.setOutput('ref', outputs.ref);
|
||||||
|
core.setOutput('ref-name', outputs.refName);
|
||||||
|
core.setOutput('sha', outputs.sha);
|
||||||
|
core.setOutput('is-tag', outputs.isTag ? 'true' : 'false');
|
||||||
|
core.setOutput('is-release-target', outputs.isReleaseTarget ? 'true' : 'false');
|
||||||
|
core.setOutput('is-beta-rc', outputs.isBetaRc ? 'true' : 'false');
|
||||||
|
build-docker-image:
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.should-build == 'true'
|
||||||
|
name: Build Docker image for ${{ needs.prepare.outputs.ref-name }}
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-build-docker-image-${{ needs.prepare.outputs.ref-name || needs.prepare.outputs.sha }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
env:
|
||||||
|
REF: ${{ needs.prepare.outputs.ref }}
|
||||||
|
REF_NAME: ${{ needs.prepare.outputs.ref-name }}
|
||||||
|
SHA: ${{ needs.prepare.outputs.sha }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.SHA }}
|
||||||
|
- name: Check pushing to Docker Hub
|
||||||
|
id: push-other-places
|
||||||
|
env:
|
||||||
|
REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||||
|
REF_NAME: ${{ env.REF_NAME }}
|
||||||
|
REF: ${{ env.REF }}
|
||||||
|
run: |
|
||||||
|
if [[ "$REPOSITORY_OWNER" == "paperless-ngx" ]] && \
|
||||||
|
([[ "$REF_NAME" == "dev" ]] || [[ "$REF_NAME" == "beta" ]] || [[ "$REF" == refs/tags/v* ]]); then
|
||||||
|
echo "Enabling DockerHub image push"
|
||||||
|
echo "enable=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "Not pushing to DockerHub"
|
||||||
|
echo "enable=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
- name: Set ghcr repository name
|
||||||
|
id: set-ghcr-repository
|
||||||
|
run: |
|
||||||
|
ghcr_name=$(echo "${{ github.repository }}" | awk '{ print tolower($0) }')
|
||||||
|
echo "Name is ${ghcr_name}"
|
||||||
|
echo "ghcr-repository=${ghcr_name}" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Gather Docker metadata
|
||||||
|
id: docker-meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
env:
|
||||||
|
GITHUB_REF: ${{ env.REF }}
|
||||||
|
GITHUB_REF_NAME: ${{ env.REF_NAME }}
|
||||||
|
GITHUB_SHA: ${{ env.SHA }}
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}
|
||||||
|
name=paperlessngx/paperless-ngx,enable=${{ steps.push-other-places.outputs.enable }}
|
||||||
|
name=quay.io/paperlessngx/paperless-ngx,enable=${{ steps.push-other-places.outputs.enable }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
with:
|
||||||
|
platforms: arm64
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
if: steps.push-other-places.outputs.enable == 'true'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
- name: Login to Quay.io
|
||||||
|
if: steps.push-other-places.outputs.enable == 'true'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: quay.io
|
||||||
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
|
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.docker-meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.docker-meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
PNGX_TAG_VERSION=${{ steps.docker-meta.outputs.version }}
|
||||||
|
cache-from: |
|
||||||
|
type=registry,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:${{ env.REF_NAME }}
|
||||||
|
type=registry,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:dev
|
||||||
|
cache-to: |
|
||||||
|
type=registry,mode=max,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:${{ env.REF_NAME }}
|
||||||
|
- name: Inspect image
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect ${{ fromJSON(steps.docker-meta.outputs.json).tags[0] }}
|
||||||
|
- name: Export frontend artifact from docker
|
||||||
|
run: |
|
||||||
|
docker create --name frontend-extract ${{ fromJSON(steps.docker-meta.outputs.json).tags[0] }}
|
||||||
|
docker cp frontend-extract:/usr/src/paperless/src/documents/static/frontend src/documents/static/frontend/
|
||||||
|
- name: Upload frontend artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-compiled
|
||||||
|
path: src/documents/static/frontend/
|
||||||
|
retention-days: 7
|
||||||
|
build-release:
|
||||||
|
needs:
|
||||||
|
- prepare
|
||||||
|
- build-docker-image
|
||||||
|
if: needs.prepare.outputs.should-build == 'true'
|
||||||
|
name: Build release bundle
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ needs.prepare.outputs.ref-name }}
|
||||||
|
SHA: ${{ needs.prepare.outputs.sha }}
|
||||||
|
CI_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.SHA }}
|
||||||
|
- name: Set up Python
|
||||||
|
id: setup-python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v6
|
||||||
|
with:
|
||||||
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
|
enable-cache: true
|
||||||
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -qq --no-install-recommends gettext liblept5
|
||||||
|
- name: Download frontend artifact
|
||||||
|
uses: actions/download-artifact@v5
|
||||||
|
with:
|
||||||
|
name: frontend-compiled
|
||||||
|
path: src/documents/static/frontend/
|
||||||
|
- name: Download documentation artifact
|
||||||
|
uses: actions/download-artifact@v5
|
||||||
|
with:
|
||||||
|
name: documentation
|
||||||
|
path: docs/_build/html/
|
||||||
|
run-id: ${{ env.CI_RUN_ID }}
|
||||||
|
- name: Generate requirements file
|
||||||
|
run: |
|
||||||
|
uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt
|
||||||
|
- name: Compile messages
|
||||||
|
run: |
|
||||||
|
cd src/
|
||||||
|
uv run \
|
||||||
|
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||||
|
manage.py compilemessages
|
||||||
|
- name: Collect static files
|
||||||
|
run: |
|
||||||
|
cd src/
|
||||||
|
uv run \
|
||||||
|
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||||
|
manage.py collectstatic --no-input
|
||||||
|
- name: Move files
|
||||||
|
run: |
|
||||||
|
echo "Making dist folders"
|
||||||
|
for directory in dist \
|
||||||
|
dist/paperless-ngx \
|
||||||
|
dist/paperless-ngx/scripts;
|
||||||
|
do
|
||||||
|
mkdir --verbose --parents ${directory}
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Copying basic files"
|
||||||
|
for file_name in .dockerignore \
|
||||||
|
.env \
|
||||||
|
Dockerfile \
|
||||||
|
pyproject.toml \
|
||||||
|
uv.lock \
|
||||||
|
requirements.txt \
|
||||||
|
LICENSE \
|
||||||
|
README.md \
|
||||||
|
paperless.conf.example
|
||||||
|
do
|
||||||
|
cp --verbose ${file_name} dist/paperless-ngx/
|
||||||
|
done
|
||||||
|
mv --verbose dist/paperless-ngx/paperless.conf.example dist/paperless-ngx/paperless.conf
|
||||||
|
|
||||||
|
echo "Copying Docker related files"
|
||||||
|
cp --recursive docker/ dist/paperless-ngx/docker
|
||||||
|
|
||||||
|
echo "Copying startup scripts"
|
||||||
|
cp --verbose scripts/*.service scripts/*.sh scripts/*.socket dist/paperless-ngx/scripts/
|
||||||
|
|
||||||
|
echo "Copying source files"
|
||||||
|
cp --recursive src/ dist/paperless-ngx/src
|
||||||
|
echo "Copying documentation"
|
||||||
|
cp --recursive docs/_build/html/ dist/paperless-ngx/docs
|
||||||
|
|
||||||
|
mv --verbose static dist/paperless-ngx
|
||||||
|
- name: Make release package
|
||||||
|
run: |
|
||||||
|
echo "Creating release archive"
|
||||||
|
cd dist
|
||||||
|
sudo chown -R 1000:1000 paperless-ngx/
|
||||||
|
tar -cJf paperless-ngx.tar.xz paperless-ngx/
|
||||||
|
- name: Upload release artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release
|
||||||
|
path: dist/paperless-ngx.tar.xz
|
||||||
|
retention-days: 7
|
||||||
|
publish-release:
|
||||||
|
needs:
|
||||||
|
- prepare
|
||||||
|
- build-release
|
||||||
|
if: needs.prepare.outputs.is-release-target == 'true'
|
||||||
|
name: Publish release
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
outputs:
|
||||||
|
prerelease: ${{ steps.get_version.outputs.prerelease }}
|
||||||
|
changelog: ${{ steps.create-release.outputs.body }}
|
||||||
|
version: ${{ steps.get_version.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- name: Download release artifact
|
||||||
|
uses: actions/download-artifact@v5
|
||||||
|
with:
|
||||||
|
name: release
|
||||||
|
path: ./
|
||||||
|
- name: Get version
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
echo "version=${{ needs.prepare.outputs.ref-name }}" >> "$GITHUB_OUTPUT"
|
||||||
|
if [[ ${{ needs.prepare.outputs.is-beta-rc }} == 'true' ]]; then
|
||||||
|
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
- name: Create Release and Changelog
|
||||||
|
id: create-release
|
||||||
|
uses: release-drafter/release-drafter@v6
|
||||||
|
with:
|
||||||
|
name: Paperless-ngx ${{ steps.get_version.outputs.version }}
|
||||||
|
tag: ${{ steps.get_version.outputs.version }}
|
||||||
|
version: ${{ steps.get_version.outputs.version }}
|
||||||
|
prerelease: ${{ steps.get_version.outputs.prerelease }}
|
||||||
|
publish: true
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Upload release archive
|
||||||
|
id: upload-release-asset
|
||||||
|
uses: shogo82148/actions-upload-release-asset@v1
|
||||||
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
upload_url: ${{ steps.create-release.outputs.upload_url }}
|
||||||
|
asset_path: ./paperless-ngx.tar.xz
|
||||||
|
asset_name: paperless-ngx-${{ steps.get_version.outputs.version }}.tar.xz
|
||||||
|
asset_content_type: application/x-xz
|
||||||
|
append-changelog:
|
||||||
|
needs:
|
||||||
|
- publish-release
|
||||||
|
if: needs.publish-release.outputs.prerelease == 'false'
|
||||||
|
name: Append changelog to docs
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
- name: Set up Python
|
||||||
|
id: setup-python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v6
|
||||||
|
with:
|
||||||
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
|
enable-cache: true
|
||||||
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
|
- name: Append Changelog to docs
|
||||||
|
id: append-Changelog
|
||||||
|
working-directory: docs
|
||||||
|
run: |
|
||||||
|
git branch ${{ needs.publish-release.outputs.version }}-changelog
|
||||||
|
git checkout ${{ needs.publish-release.outputs.version }}-changelog
|
||||||
|
echo -e "# Changelog\n\n${{ needs.publish-release.outputs.changelog }}\n" > changelog-new.md
|
||||||
|
echo "Manually linking usernames"
|
||||||
|
sed -i -r 's|@([a-zA-Z0-9_]+) \(\[#|[@\1](https://github.com/\1) ([#|g' changelog-new.md
|
||||||
|
echo "Removing unneeded comment tags"
|
||||||
|
sed -i -r 's|@<!---->|@|g' changelog-new.md
|
||||||
|
CURRENT_CHANGELOG=`tail --lines +2 changelog.md`
|
||||||
|
echo -e "$CURRENT_CHANGELOG" >> changelog-new.md
|
||||||
|
mv changelog-new.md changelog.md
|
||||||
|
uv run \
|
||||||
|
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||||
|
--dev \
|
||||||
|
pre-commit run --files changelog.md || true
|
||||||
|
git config --global user.name "github-actions"
|
||||||
|
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA"
|
||||||
|
git push origin ${{ needs.publish-release.outputs.version }}-changelog
|
||||||
|
- name: Create Pull Request
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const { repo, owner } = context.repo;
|
||||||
|
const result = await github.rest.pulls.create({
|
||||||
|
title: 'Documentation: Add ${{ needs.publish-release.outputs.version }} changelog',
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
head: '${{ needs.publish-release.outputs.version }}-changelog',
|
||||||
|
base: 'main',
|
||||||
|
body: 'This PR is auto-generated by CI.'
|
||||||
|
});
|
||||||
|
github.rest.issues.addLabels({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: result.data.number,
|
||||||
|
labels: ['documentation', 'skip-changelog']
|
||||||
|
});
|
819
.github/workflows/ci.yml
vendored
819
.github/workflows/ci.yml
vendored
@@ -17,52 +17,11 @@ env:
|
|||||||
DEFAULT_PYTHON_VERSION: "3.11"
|
DEFAULT_PYTHON_VERSION: "3.11"
|
||||||
NLTK_DATA: "/usr/share/nltk_data"
|
NLTK_DATA: "/usr/share/nltk_data"
|
||||||
jobs:
|
jobs:
|
||||||
detect-duplicate:
|
|
||||||
name: Detect Duplicate Run
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
outputs:
|
|
||||||
should_run: ${{ steps.check.outputs.should_run }}
|
|
||||||
steps:
|
|
||||||
- name: Check if workflow should run
|
|
||||||
id: check
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
script: |
|
|
||||||
if (context.eventName !== 'push') {
|
|
||||||
core.info('Not a push event; running workflow.');
|
|
||||||
core.setOutput('should_run', 'true');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ref = context.ref || '';
|
|
||||||
if (!ref.startsWith('refs/heads/')) {
|
|
||||||
core.info('Push is not to a branch; running workflow.');
|
|
||||||
core.setOutput('should_run', 'true');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const branch = ref.substring('refs/heads/'.length);
|
|
||||||
const { owner, repo } = context.repo;
|
|
||||||
const prs = await github.paginate(github.rest.pulls.list, {
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
state: 'open',
|
|
||||||
head: `${owner}:${branch}`,
|
|
||||||
per_page: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (prs.length === 0) {
|
|
||||||
core.info(`No open PR found for ${branch}; running workflow.`);
|
|
||||||
core.setOutput('should_run', 'true');
|
|
||||||
} else {
|
|
||||||
core.info(`Found ${prs.length} open PR(s) for ${branch}; skipping duplicate push run.`);
|
|
||||||
core.setOutput('should_run', 'false');
|
|
||||||
}
|
|
||||||
pre-commit:
|
pre-commit:
|
||||||
needs:
|
# We want to run on external PRs, but not on our own internal PRs as they'll be run
|
||||||
- detect-duplicate
|
# by the push to the branch. Without this if check, checks are duplicated since
|
||||||
if: needs.detect-duplicate.outputs.should_run == 'true'
|
# internal PRs match both the push and pull_request events.
|
||||||
|
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
|
||||||
name: Linting Checks
|
name: Linting Checks
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
@@ -322,455 +281,6 @@ jobs:
|
|||||||
run: cd src-ui && pnpm exec playwright install
|
run: cd src-ui && pnpm exec playwright install
|
||||||
- name: Run Playwright e2e tests
|
- name: Run Playwright e2e tests
|
||||||
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||||
codecov-comment:
|
|
||||||
name: "Codecov PR Comment"
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
needs:
|
|
||||||
- tests-backend
|
|
||||||
- tests-frontend
|
|
||||||
- tests-frontend-e2e
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- name: Gather pull request context
|
|
||||||
id: pr
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const pr = context.payload.pull_request;
|
|
||||||
if (!pr) {
|
|
||||||
core.info('No associated pull request. Skipping.');
|
|
||||||
core.setOutput('shouldRun', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
core.setOutput('shouldRun', 'true');
|
|
||||||
core.setOutput('prNumber', pr.number.toString());
|
|
||||||
core.setOutput('headSha', pr.head.sha);
|
|
||||||
- name: Fetch Codecov coverage
|
|
||||||
id: coverage
|
|
||||||
if: steps.pr.outputs.shouldRun == 'true'
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
env:
|
|
||||||
COMMIT_SHA: ${{ steps.pr.outputs.headSha }}
|
|
||||||
PR_NUMBER: ${{ steps.pr.outputs.prNumber }}
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const commitSha = process.env.COMMIT_SHA;
|
|
||||||
const prNumber = process.env.PR_NUMBER;
|
|
||||||
const owner = context.repo.owner;
|
|
||||||
const repo = context.repo.repo;
|
|
||||||
const service = 'gh';
|
|
||||||
const baseUrl = `https://api.codecov.io/api/v2/${service}/${owner}/repos/${repo}`;
|
|
||||||
const commitUrl = `${baseUrl}/commits/${commitSha}`;
|
|
||||||
const maxAttempts = 20;
|
|
||||||
const waitMs = 15000;
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
let data;
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
||||||
core.info(`Fetching Codecov report (attempt ${attempt}/${maxAttempts})`);
|
|
||||||
let response;
|
|
||||||
try {
|
|
||||||
response = await fetch(commitUrl, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
core.warning(`Codecov fetch failed: ${error}. Waiting before retrying.`);
|
|
||||||
await sleep(waitMs);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 404) {
|
|
||||||
core.info('Report not ready yet (404). Waiting before retrying.');
|
|
||||||
await sleep(waitMs);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([429, 500, 502, 503, 504].includes(response.status)) {
|
|
||||||
const text = await response.text().catch(() => '');
|
|
||||||
core.info(`Codecov API transient error ${response.status}: ${text}. Waiting before retrying.`);
|
|
||||||
await sleep(waitMs);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text().catch(() => '');
|
|
||||||
core.warning(`Codecov API returned ${response.status}: ${text}. Skipping comment.`);
|
|
||||||
core.setOutput('shouldComment', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
data = await response.json().catch((error) => {
|
|
||||||
core.warning(`Failed to parse Codecov response: ${error}.`);
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
if (data && Object.keys(data).length > 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
core.info('Report payload empty. Waiting before retrying.');
|
|
||||||
await sleep(waitMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data && prNumber) {
|
|
||||||
core.info('Attempting to retrieve coverage from PR endpoint.');
|
|
||||||
const prUrl = `${baseUrl}/pulls/${prNumber}`;
|
|
||||||
let prResponse;
|
|
||||||
try {
|
|
||||||
prResponse = await fetch(prUrl, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
core.warning(`Codecov PR fetch failed: ${error}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prResponse) {
|
|
||||||
if ([429, 500, 502, 503, 504].includes(prResponse.status)) {
|
|
||||||
const text = await prResponse.text().catch(() => '');
|
|
||||||
core.info(`Codecov PR endpoint transient error ${prResponse.status}: ${text}.`);
|
|
||||||
} else if (!prResponse.ok) {
|
|
||||||
const text = await prResponse.text().catch(() => '');
|
|
||||||
core.warning(`Codecov PR endpoint returned ${prResponse.status}: ${text}.`);
|
|
||||||
} else {
|
|
||||||
const prData = await prResponse.json().catch((error) => {
|
|
||||||
core.warning(`Failed to parse Codecov PR response: ${error}.`);
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (prData?.latest_report) {
|
|
||||||
data = { report: prData.latest_report };
|
|
||||||
} else if (prData?.head_totals) {
|
|
||||||
const headTotals = prData.head_totals;
|
|
||||||
const baseTotals = prData.base_totals;
|
|
||||||
let compareTotals;
|
|
||||||
if (baseTotals && headTotals) {
|
|
||||||
const headCoverage = Number(headTotals.coverage);
|
|
||||||
const baseCoverage = Number(baseTotals.coverage);
|
|
||||||
if (Number.isFinite(headCoverage) && Number.isFinite(baseCoverage)) {
|
|
||||||
compareTotals = {
|
|
||||||
base_coverage: baseCoverage,
|
|
||||||
coverage_change: headCoverage - baseCoverage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data = {
|
|
||||||
report: {
|
|
||||||
totals: headTotals,
|
|
||||||
compare: compareTotals ? { totals: compareTotals } : undefined,
|
|
||||||
totals_by_flag: [],
|
|
||||||
},
|
|
||||||
head_totals: headTotals,
|
|
||||||
base_totals: baseTotals,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
data = prData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
core.warning('Unable to retrieve Codecov report after multiple attempts.');
|
|
||||||
core.setOutput('shouldComment', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toNumber = (value) => {
|
|
||||||
if (value === null || value === undefined || value === '') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const num = Number(value);
|
|
||||||
return Number.isFinite(num) ? num : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const reportData = data.report || data;
|
|
||||||
const totals = reportData.totals ?? data.head_totals ?? data.totals;
|
|
||||||
if (!totals) {
|
|
||||||
core.warning('Codecov response does not contain coverage totals.');
|
|
||||||
core.setOutput('shouldComment', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let compareTotals = reportData.compare?.totals ?? data.compare?.totals;
|
|
||||||
if (!compareTotals && data.base_totals) {
|
|
||||||
const baseCoverageValue = toNumber(data.base_totals.coverage);
|
|
||||||
if (baseCoverageValue !== undefined) {
|
|
||||||
const headCoverageValue = toNumber((data.head_totals ?? {}).coverage);
|
|
||||||
compareTotals = {
|
|
||||||
base_coverage: baseCoverageValue,
|
|
||||||
coverage_change:
|
|
||||||
headCoverageValue !== undefined ? headCoverageValue - baseCoverageValue : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const coverage = toNumber(totals.coverage);
|
|
||||||
const baseCoverage = toNumber(compareTotals?.base_coverage ?? compareTotals?.base);
|
|
||||||
let delta = toNumber(
|
|
||||||
compareTotals?.coverage_change ??
|
|
||||||
compareTotals?.coverage_diff ??
|
|
||||||
totals.delta ??
|
|
||||||
totals.diff ??
|
|
||||||
totals.change,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (delta === undefined && coverage !== undefined && baseCoverage !== undefined) {
|
|
||||||
delta = coverage - baseCoverage;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatPercent = (value) => {
|
|
||||||
if (value === undefined) return '—';
|
|
||||||
return `${value.toFixed(2)}%`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDelta = (value) => {
|
|
||||||
if (value === undefined) return '—';
|
|
||||||
const sign = value >= 0 ? '+' : '';
|
|
||||||
return `${sign}${value.toFixed(2)}%`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const shortSha = commitSha.slice(0, 7);
|
|
||||||
const reportBaseUrl = `https://app.codecov.io/gh/${owner}/${repo}`;
|
|
||||||
const commitReportUrl = `${reportBaseUrl}/commit/${commitSha}?src=pr&el=comment`;
|
|
||||||
const prReportUrl = prNumber
|
|
||||||
? `${reportBaseUrl}/pull/${prNumber}?src=pr&el=comment`
|
|
||||||
: commitReportUrl;
|
|
||||||
|
|
||||||
const findBaseCommitSha = () =>
|
|
||||||
data?.report?.compare?.base_commitid ??
|
|
||||||
data?.report?.compare?.base?.commitid ??
|
|
||||||
data?.report?.base_commitid ??
|
|
||||||
data?.compare?.base_commitid ??
|
|
||||||
data?.compare?.base?.commitid ??
|
|
||||||
data?.base_commitid ??
|
|
||||||
data?.base?.commitid;
|
|
||||||
|
|
||||||
const baseCommitSha = findBaseCommitSha();
|
|
||||||
const baseCommitUrl = baseCommitSha
|
|
||||||
? `${reportBaseUrl}/commit/${baseCommitSha}?src=pr&el=comment`
|
|
||||||
: undefined;
|
|
||||||
const baseShortSha = baseCommitSha ? baseCommitSha.slice(0, 7) : undefined;
|
|
||||||
|
|
||||||
const lines = ['<!-- codecov-coverage-comment -->'];
|
|
||||||
lines.push(`## [Codecov](${prReportUrl}) Report`);
|
|
||||||
lines.push('');
|
|
||||||
|
|
||||||
if (coverage !== undefined) {
|
|
||||||
lines.push(`:white_check_mark: Project coverage for \`${shortSha}\` is ${formatPercent(coverage)}.`);
|
|
||||||
} else {
|
|
||||||
lines.push(':warning: Coverage for the head commit is unavailable.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (baseCoverage !== undefined) {
|
|
||||||
const changeEmoji = delta === undefined ? ':grey_question:' : delta >= 0 ? ':white_check_mark:' : ':small_red_triangle_down:';
|
|
||||||
const baseCoverageText = `Base${baseShortSha ? ` \`${baseShortSha}\`` : ''} ${formatPercent(baseCoverage)}`;
|
|
||||||
const baseLink = baseCommitUrl ? `[${baseCoverageText}](${baseCommitUrl})` : baseCoverageText;
|
|
||||||
const changeText =
|
|
||||||
delta !== undefined
|
|
||||||
? `${baseLink} (${formatDelta(delta)})`
|
|
||||||
: `${baseLink} (change unknown)`;
|
|
||||||
lines.push(`${changeEmoji} ${changeText}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(`:clipboard: [View full report on Codecov](${commitReportUrl}).`);
|
|
||||||
|
|
||||||
const normalizeTotals = (value) => {
|
|
||||||
if (!value) return undefined;
|
|
||||||
if (value.totals && typeof value.totals === 'object') return value.totals;
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const headTotals = normalizeTotals(totals) ?? {};
|
|
||||||
const baseTotals =
|
|
||||||
normalizeTotals(data.base_totals) ??
|
|
||||||
normalizeTotals(reportData.base_totals) ??
|
|
||||||
normalizeTotals(reportData.compare?.base_totals) ??
|
|
||||||
normalizeTotals(reportData.compare?.base);
|
|
||||||
|
|
||||||
const formatInteger = (value) => {
|
|
||||||
if (value === undefined) return '—';
|
|
||||||
return value.toLocaleString('en-US');
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatIntegerDelta = (value) => {
|
|
||||||
if (value === undefined) return '—';
|
|
||||||
const sign = value >= 0 ? '+' : '';
|
|
||||||
return `${sign}${value.toLocaleString('en-US')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getInteger = (value) => {
|
|
||||||
const num = toNumber(value);
|
|
||||||
return Number.isFinite(num) ? Math.round(num) : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const metrics = [];
|
|
||||||
metrics.push({
|
|
||||||
label: 'Coverage',
|
|
||||||
base: baseCoverage,
|
|
||||||
head: coverage,
|
|
||||||
diff: delta,
|
|
||||||
format: formatPercent,
|
|
||||||
formatDiff: formatDelta,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pushIntegerMetric = (label, headValueRaw, baseValueRaw) => {
|
|
||||||
const headValue = getInteger(headValueRaw);
|
|
||||||
const baseValue = getInteger(baseValueRaw);
|
|
||||||
if (headValue === undefined && baseValue === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const diff = headValue !== undefined && baseValue !== undefined ? headValue - baseValue : undefined;
|
|
||||||
metrics.push({
|
|
||||||
label,
|
|
||||||
base: baseValue,
|
|
||||||
head: headValue,
|
|
||||||
diff,
|
|
||||||
format: formatInteger,
|
|
||||||
formatDiff: formatIntegerDelta,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
pushIntegerMetric('Files', headTotals.files, baseTotals?.files);
|
|
||||||
pushIntegerMetric('Lines', headTotals.lines, baseTotals?.lines);
|
|
||||||
pushIntegerMetric('Branches', headTotals.branches, baseTotals?.branches);
|
|
||||||
pushIntegerMetric('Hits', headTotals.hits, baseTotals?.hits);
|
|
||||||
pushIntegerMetric('Misses', headTotals.misses, baseTotals?.misses);
|
|
||||||
|
|
||||||
const hasMetricData = metrics.some((metric) => metric.base !== undefined || metric.head !== undefined);
|
|
||||||
if (hasMetricData) {
|
|
||||||
lines.push('');
|
|
||||||
lines.push('<details><summary>Coverage summary</summary>');
|
|
||||||
lines.push('');
|
|
||||||
lines.push('| Metric | Base | Head | Δ |');
|
|
||||||
lines.push('| --- | --- | --- | --- |');
|
|
||||||
for (const metric of metrics) {
|
|
||||||
const baseValue = metric.base !== undefined ? metric.format(metric.base) : '—';
|
|
||||||
const headValue = metric.head !== undefined ? metric.format(metric.head) : '—';
|
|
||||||
const diffValue = metric.diff !== undefined ? metric.formatDiff(metric.diff) : '—';
|
|
||||||
lines.push(`| ${metric.label} | ${baseValue} | ${headValue} | ${diffValue} |`);
|
|
||||||
}
|
|
||||||
lines.push('');
|
|
||||||
lines.push('</details>');
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeEntries = (raw) => {
|
|
||||||
if (!raw) return [];
|
|
||||||
if (Array.isArray(raw)) return raw;
|
|
||||||
if (typeof raw === 'object') {
|
|
||||||
return Object.entries(raw).map(([name, totals]) => ({ name, ...(typeof totals === 'object' ? totals : { coverage: totals }) }));
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildTableRows = (entries) => {
|
|
||||||
const rows = [];
|
|
||||||
for (const entry of entries) {
|
|
||||||
const label = entry.flag ?? entry.name ?? entry.component ?? entry.id;
|
|
||||||
const entryTotals = entry.totals ?? entry;
|
|
||||||
const entryCoverage = toNumber(entryTotals?.coverage);
|
|
||||||
if (!label || entryCoverage === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const entryDelta = toNumber(
|
|
||||||
entryTotals?.coverage_change ??
|
|
||||||
entryTotals?.coverage_diff ??
|
|
||||||
entryTotals?.delta ??
|
|
||||||
entryTotals?.diff ??
|
|
||||||
entryTotals?.change,
|
|
||||||
);
|
|
||||||
const coverageText = entryCoverage !== undefined ? `\`${formatPercent(entryCoverage)}\`` : '—';
|
|
||||||
const deltaText = entryDelta !== undefined ? `\`${formatDelta(entryDelta)}\`` : '—';
|
|
||||||
rows.push(`| ${label} | ${coverageText} | ${deltaText} |`);
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
const componentEntries = normalizeEntries(reportData.components ?? data.components);
|
|
||||||
const flagEntries = normalizeEntries(reportData.totals_by_flag ?? data.totals_by_flag);
|
|
||||||
|
|
||||||
if (componentEntries.length) {
|
|
||||||
const componentsLink = prNumber
|
|
||||||
? `${reportBaseUrl}/pull/${prNumber}/components?src=pr&el=components`
|
|
||||||
: `${commitReportUrl}`;
|
|
||||||
const componentRows = buildTableRows(componentEntries);
|
|
||||||
if (componentRows.length) {
|
|
||||||
lines.push('');
|
|
||||||
lines.push(`[Components report](${componentsLink})`);
|
|
||||||
lines.push('');
|
|
||||||
lines.push('| Component | Coverage | Δ |');
|
|
||||||
lines.push('| --- | --- | --- |');
|
|
||||||
lines.push(...componentRows);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (flagEntries.length) {
|
|
||||||
const flagsLink = prNumber
|
|
||||||
? `${reportBaseUrl}/pull/${prNumber}/flags?src=pr&el=flags`
|
|
||||||
: `${commitReportUrl}`;
|
|
||||||
const flagRows = buildTableRows(flagEntries);
|
|
||||||
if (flagRows.length) {
|
|
||||||
lines.push('');
|
|
||||||
lines.push(`[Flags report](${flagsLink})`);
|
|
||||||
lines.push('');
|
|
||||||
lines.push('| Flag | Coverage | Δ |');
|
|
||||||
lines.push('| --- | --- | --- |');
|
|
||||||
lines.push(...flagRows);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const commentBody = lines.join('\n');
|
|
||||||
const shouldComment = coverage !== undefined;
|
|
||||||
core.setOutput('shouldComment', shouldComment ? 'true' : 'false');
|
|
||||||
if (shouldComment) {
|
|
||||||
core.setOutput('commentBody', commentBody);
|
|
||||||
}
|
|
||||||
- name: Upsert coverage comment
|
|
||||||
if: steps.pr.outputs.shouldRun == 'true' && steps.coverage.outputs.shouldComment == 'true'
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
env:
|
|
||||||
PR_NUMBER: ${{ steps.pr.outputs.prNumber }}
|
|
||||||
COMMENT_BODY: ${{ steps.coverage.outputs.commentBody }}
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const prNumber = Number(process.env.PR_NUMBER);
|
|
||||||
const body = process.env.COMMENT_BODY;
|
|
||||||
const marker = '<!-- codecov-coverage-comment -->';
|
|
||||||
|
|
||||||
const { data: comments } = await github.rest.issues.listComments({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: prNumber,
|
|
||||||
per_page: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
const existing = comments.find((comment) => comment.body?.includes(marker));
|
|
||||||
if (existing) {
|
|
||||||
core.info(`Updating existing coverage comment (id: ${existing.id}).`);
|
|
||||||
await github.rest.issues.updateComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
comment_id: existing.id,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
core.info('Creating new coverage comment.');
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: prNumber,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
frontend-bundle-analysis:
|
frontend-bundle-analysis:
|
||||||
name: "Frontend Bundle Analysis"
|
name: "Frontend Bundle Analysis"
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -803,324 +313,3 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
run: cd src-ui && pnpm run build --configuration=production
|
run: cd src-ui && pnpm run build --configuration=production
|
||||||
build-docker-image:
|
|
||||||
name: Build Docker image for ${{ github.ref_name }}
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || startsWith(github.ref, 'refs/heads/fix-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/l10n_'))
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
needs:
|
|
||||||
- tests-backend
|
|
||||||
- tests-frontend
|
|
||||||
- tests-frontend-e2e
|
|
||||||
steps:
|
|
||||||
- name: Check pushing to Docker Hub
|
|
||||||
id: push-other-places
|
|
||||||
# Only push to Dockerhub from the main repo AND the ref is either:
|
|
||||||
# main
|
|
||||||
# dev
|
|
||||||
# beta
|
|
||||||
# a tag
|
|
||||||
# Otherwise forks would require a Docker Hub account and secrets setup
|
|
||||||
run: |
|
|
||||||
if [[ ${{ github.repository_owner }} == "paperless-ngx" && ( ${{ github.ref_name }} == "dev" || ${{ github.ref_name }} == "beta" || ${{ startsWith(github.ref, 'refs/tags/v') }} == "true" ) ]] ; then
|
|
||||||
echo "Enabling DockerHub image push"
|
|
||||||
echo "enable=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "Not pushing to DockerHub"
|
|
||||||
echo "enable=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
- name: Set ghcr repository name
|
|
||||||
id: set-ghcr-repository
|
|
||||||
run: |
|
|
||||||
ghcr_name=$(echo "${{ github.repository }}" | awk '{ print tolower($0) }')
|
|
||||||
echo "Name is ${ghcr_name}"
|
|
||||||
echo "ghcr-repository=${ghcr_name}" >> $GITHUB_OUTPUT
|
|
||||||
- name: Gather Docker metadata
|
|
||||||
id: docker-meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}
|
|
||||||
name=paperlessngx/paperless-ngx,enable=${{ steps.push-other-places.outputs.enable }}
|
|
||||||
name=quay.io/paperlessngx/paperless-ngx,enable=${{ steps.push-other-places.outputs.enable }}
|
|
||||||
tags: |
|
|
||||||
# Tag branches with branch name
|
|
||||||
type=ref,event=branch
|
|
||||||
# Process semver tags
|
|
||||||
# For a tag x.y.z or vX.Y.Z, output an x.y.z and x.y image tag
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
# If https://github.com/docker/buildx/issues/1044 is resolved,
|
|
||||||
# the append input with a native arm64 arch could be used to
|
|
||||||
# significantly speed up building
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
with:
|
|
||||||
platforms: arm64
|
|
||||||
- name: Login to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
# Don't attempt to login if not pushing to Docker Hub
|
|
||||||
if: steps.push-other-places.outputs.enable == 'true'
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
- name: Login to Quay.io
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
# Don't attempt to login if not pushing to Quay.io
|
|
||||||
if: steps.push-other-places.outputs.enable == 'true'
|
|
||||||
with:
|
|
||||||
registry: quay.io
|
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
|
||||||
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
|
||||||
tags: ${{ steps.docker-meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.docker-meta.outputs.labels }}
|
|
||||||
build-args: |
|
|
||||||
PNGX_TAG_VERSION=${{ steps.docker-meta.outputs.version }}
|
|
||||||
# Get cache layers from this branch, then dev
|
|
||||||
# This allows new branches to get at least some cache benefits, generally from dev
|
|
||||||
cache-from: |
|
|
||||||
type=registry,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:${{ github.ref_name }}
|
|
||||||
type=registry,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:dev
|
|
||||||
cache-to: |
|
|
||||||
type=registry,mode=max,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:${{ github.ref_name }}
|
|
||||||
- name: Inspect image
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools inspect ${{ fromJSON(steps.docker-meta.outputs.json).tags[0] }}
|
|
||||||
- name: Export frontend artifact from docker
|
|
||||||
run: |
|
|
||||||
docker create --name frontend-extract ${{ fromJSON(steps.docker-meta.outputs.json).tags[0] }}
|
|
||||||
docker cp frontend-extract:/usr/src/paperless/src/documents/static/frontend src/documents/static/frontend/
|
|
||||||
- name: Upload frontend artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: frontend-compiled
|
|
||||||
path: src/documents/static/frontend/
|
|
||||||
retention-days: 7
|
|
||||||
build-release:
|
|
||||||
name: "Build Release"
|
|
||||||
needs:
|
|
||||||
- build-docker-image
|
|
||||||
- documentation
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
- name: Set up Python
|
|
||||||
id: setup-python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v6
|
|
||||||
with:
|
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
|
||||||
enable-cache: true
|
|
||||||
python-version: ${{ steps.setup-python.outputs.python-version }}
|
|
||||||
- name: Install Python dependencies
|
|
||||||
run: |
|
|
||||||
uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
|
|
||||||
- name: Install system dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -qq
|
|
||||||
sudo apt-get install -qq --no-install-recommends gettext liblept5
|
|
||||||
- name: Download frontend artifact
|
|
||||||
uses: actions/download-artifact@v5
|
|
||||||
with:
|
|
||||||
name: frontend-compiled
|
|
||||||
path: src/documents/static/frontend/
|
|
||||||
- name: Download documentation artifact
|
|
||||||
uses: actions/download-artifact@v5
|
|
||||||
with:
|
|
||||||
name: documentation
|
|
||||||
path: docs/_build/html/
|
|
||||||
- name: Generate requirements file
|
|
||||||
run: |
|
|
||||||
uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt
|
|
||||||
- name: Compile messages
|
|
||||||
run: |
|
|
||||||
cd src/
|
|
||||||
uv run \
|
|
||||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
|
||||||
manage.py compilemessages
|
|
||||||
- name: Collect static files
|
|
||||||
run: |
|
|
||||||
cd src/
|
|
||||||
uv run \
|
|
||||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
|
||||||
manage.py collectstatic --no-input
|
|
||||||
- name: Move files
|
|
||||||
run: |
|
|
||||||
echo "Making dist folders"
|
|
||||||
for directory in dist \
|
|
||||||
dist/paperless-ngx \
|
|
||||||
dist/paperless-ngx/scripts;
|
|
||||||
do
|
|
||||||
mkdir --verbose --parents ${directory}
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Copying basic files"
|
|
||||||
for file_name in .dockerignore \
|
|
||||||
.env \
|
|
||||||
Dockerfile \
|
|
||||||
pyproject.toml \
|
|
||||||
uv.lock \
|
|
||||||
requirements.txt \
|
|
||||||
LICENSE \
|
|
||||||
README.md \
|
|
||||||
paperless.conf.example
|
|
||||||
do
|
|
||||||
cp --verbose ${file_name} dist/paperless-ngx/
|
|
||||||
done
|
|
||||||
mv --verbose dist/paperless-ngx/paperless.conf.example dist/paperless-ngx/paperless.conf
|
|
||||||
|
|
||||||
echo "Copying Docker related files"
|
|
||||||
cp --recursive docker/ dist/paperless-ngx/docker
|
|
||||||
|
|
||||||
echo "Copying startup scripts"
|
|
||||||
cp --verbose scripts/*.service scripts/*.sh scripts/*.socket dist/paperless-ngx/scripts/
|
|
||||||
|
|
||||||
echo "Copying source files"
|
|
||||||
cp --recursive src/ dist/paperless-ngx/src
|
|
||||||
echo "Copying documentation"
|
|
||||||
cp --recursive docs/_build/html/ dist/paperless-ngx/docs
|
|
||||||
|
|
||||||
mv --verbose static dist/paperless-ngx
|
|
||||||
- name: Make release package
|
|
||||||
run: |
|
|
||||||
echo "Creating release archive"
|
|
||||||
cd dist
|
|
||||||
sudo chown -R 1000:1000 paperless-ngx/
|
|
||||||
tar -cJf paperless-ngx.tar.xz paperless-ngx/
|
|
||||||
- name: Upload release artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: release
|
|
||||||
path: dist/paperless-ngx.tar.xz
|
|
||||||
retention-days: 7
|
|
||||||
publish-release:
|
|
||||||
name: "Publish Release"
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
outputs:
|
|
||||||
prerelease: ${{ steps.get_version.outputs.prerelease }}
|
|
||||||
changelog: ${{ steps.create-release.outputs.body }}
|
|
||||||
version: ${{ steps.get_version.outputs.version }}
|
|
||||||
needs:
|
|
||||||
- build-release
|
|
||||||
if: github.ref_type == 'tag' && (startsWith(github.ref_name, 'v') || contains(github.ref_name, '-beta.rc'))
|
|
||||||
steps:
|
|
||||||
- name: Download release artifact
|
|
||||||
uses: actions/download-artifact@v5
|
|
||||||
with:
|
|
||||||
name: release
|
|
||||||
path: ./
|
|
||||||
- name: Get version
|
|
||||||
id: get_version
|
|
||||||
run: |
|
|
||||||
echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT
|
|
||||||
if [[ ${{ contains(github.ref_name, '-beta.rc') }} == 'true' ]]; then
|
|
||||||
echo "prerelease=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "prerelease=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
- name: Create Release and Changelog
|
|
||||||
id: create-release
|
|
||||||
uses: release-drafter/release-drafter@v6
|
|
||||||
with:
|
|
||||||
name: Paperless-ngx ${{ steps.get_version.outputs.version }}
|
|
||||||
tag: ${{ steps.get_version.outputs.version }}
|
|
||||||
version: ${{ steps.get_version.outputs.version }}
|
|
||||||
prerelease: ${{ steps.get_version.outputs.prerelease }}
|
|
||||||
publish: true # ensures release is not marked as draft
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Upload release archive
|
|
||||||
id: upload-release-asset
|
|
||||||
uses: shogo82148/actions-upload-release-asset@v1
|
|
||||||
with:
|
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
upload_url: ${{ steps.create-release.outputs.upload_url }}
|
|
||||||
asset_path: ./paperless-ngx.tar.xz
|
|
||||||
asset_name: paperless-ngx-${{ steps.get_version.outputs.version }}.tar.xz
|
|
||||||
asset_content_type: application/x-xz
|
|
||||||
append-changelog:
|
|
||||||
name: "Append Changelog"
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
needs:
|
|
||||||
- publish-release
|
|
||||||
if: needs.publish-release.outputs.prerelease == 'false'
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
ref: main
|
|
||||||
- name: Set up Python
|
|
||||||
id: setup-python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v6
|
|
||||||
with:
|
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
|
||||||
enable-cache: true
|
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
|
||||||
- name: Append Changelog to docs
|
|
||||||
id: append-Changelog
|
|
||||||
working-directory: docs
|
|
||||||
run: |
|
|
||||||
git branch ${{ needs.publish-release.outputs.version }}-changelog
|
|
||||||
git checkout ${{ needs.publish-release.outputs.version }}-changelog
|
|
||||||
echo -e "# Changelog\n\n${{ needs.publish-release.outputs.changelog }}\n" > changelog-new.md
|
|
||||||
echo "Manually linking usernames"
|
|
||||||
sed -i -r 's|@([a-zA-Z0-9_]+) \(\[#|[@\1](https://github.com/\1) ([#|g' changelog-new.md
|
|
||||||
echo "Removing unneeded comment tags"
|
|
||||||
sed -i -r 's|@<!---->|@|g' changelog-new.md
|
|
||||||
CURRENT_CHANGELOG=`tail --lines +2 changelog.md`
|
|
||||||
echo -e "$CURRENT_CHANGELOG" >> changelog-new.md
|
|
||||||
mv changelog-new.md changelog.md
|
|
||||||
uv run \
|
|
||||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
|
||||||
--dev \
|
|
||||||
pre-commit run --files changelog.md || true
|
|
||||||
git config --global user.name "github-actions"
|
|
||||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
||||||
git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA"
|
|
||||||
git push origin ${{ needs.publish-release.outputs.version }}-changelog
|
|
||||||
- name: Create Pull Request
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const { repo, owner } = context.repo;
|
|
||||||
const result = await github.rest.pulls.create({
|
|
||||||
title: 'Documentation: Add ${{ needs.publish-release.outputs.version }} changelog',
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
head: '${{ needs.publish-release.outputs.version }}-changelog',
|
|
||||||
base: 'main',
|
|
||||||
body: 'This PR is auto-generated by CI.'
|
|
||||||
});
|
|
||||||
github.rest.issues.addLabels({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: result.data.number,
|
|
||||||
labels: ['documentation', 'skip-changelog']
|
|
||||||
});
|
|
||||||
|
220
.github/workflows/codecov-comment.yml
vendored
Normal file
220
.github/workflows/codecov-comment.yml
vendored
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
name: Codecov PR Comment
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows:
|
||||||
|
- ci
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
jobs:
|
||||||
|
comment:
|
||||||
|
if: >-
|
||||||
|
github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: Gather pull request context
|
||||||
|
id: pr
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const run = context.payload.workflow_run;
|
||||||
|
if (!run.pull_requests || run.pull_requests.length === 0) {
|
||||||
|
core.info('No associated pull request. Skipping.');
|
||||||
|
return { shouldRun: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pr = run.pull_requests[0];
|
||||||
|
return {
|
||||||
|
shouldRun: true,
|
||||||
|
prNumber: pr.number,
|
||||||
|
headSha: run.head_sha,
|
||||||
|
};
|
||||||
|
- name: Fetch Codecov coverage
|
||||||
|
id: coverage
|
||||||
|
if: steps.pr.outputs.shouldRun == 'true'
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
COMMIT_SHA: ${{ steps.pr.outputs.headSha }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const token = process.env.CODECOV_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
core.warning('CODECOV_TOKEN secret is not available; skipping comment.');
|
||||||
|
core.setOutput('shouldComment', 'false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitSha = process.env.COMMIT_SHA;
|
||||||
|
const owner = context.repo.owner;
|
||||||
|
const repo = context.repo.repo;
|
||||||
|
const url = `https://codecov.io/api/v2/github/${owner}/repos/${repo}/commits/${commitSha}/report`;
|
||||||
|
const maxAttempts = 10;
|
||||||
|
const waitMs = 15000;
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
let data;
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
core.info(`Fetching Codecov report (attempt ${attempt}/${maxAttempts})`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
core.info('Report not ready yet (404). Waiting before retrying.');
|
||||||
|
await sleep(waitMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Codecov API returned ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
data = await response.json();
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
core.info('Report payload empty. Waiting before retrying.');
|
||||||
|
await sleep(waitMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
core.warning('Unable to retrieve Codecov report after multiple attempts.');
|
||||||
|
core.setOutput('shouldComment', 'false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = data.report?.totals ?? data.commit?.totals ?? data.totals;
|
||||||
|
if (!totals) {
|
||||||
|
core.warning('Codecov response does not contain coverage totals.');
|
||||||
|
core.setOutput('shouldComment', 'false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compareTotals = data.report?.compare?.totals ?? data.compare?.totals;
|
||||||
|
const flagsRaw = data.report?.totals_by_flag ?? data.report?.components ?? [];
|
||||||
|
|
||||||
|
const toNumber = (value) => {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const num = Number(value);
|
||||||
|
return Number.isFinite(num) ? num : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const coverage = toNumber(totals.coverage);
|
||||||
|
const baseCoverage = toNumber(compareTotals?.base_coverage ?? compareTotals?.base);
|
||||||
|
const delta = toNumber(
|
||||||
|
compareTotals?.coverage_change ??
|
||||||
|
compareTotals?.coverage_diff ??
|
||||||
|
totals.delta ??
|
||||||
|
totals.diff ??
|
||||||
|
totals.change,
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatPercent = (value) => {
|
||||||
|
if (value === undefined) return '—';
|
||||||
|
return `${value.toFixed(2)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDelta = (value) => {
|
||||||
|
if (value === undefined) return '—';
|
||||||
|
const sign = value >= 0 ? '+' : '';
|
||||||
|
return `${sign}${value.toFixed(2)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shortSha = commitSha.slice(0, 7);
|
||||||
|
const lines = [
|
||||||
|
'<!-- codecov-coverage-comment -->',
|
||||||
|
'**Codecov Coverage**',
|
||||||
|
'',
|
||||||
|
`- Head \`${shortSha}\`: ${formatPercent(coverage)}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (baseCoverage !== undefined) {
|
||||||
|
lines.push(`- Base: ${formatPercent(baseCoverage)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta !== undefined) {
|
||||||
|
lines.push(`- Change: ${formatDelta(delta)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const flagEntries = Array.isArray(flagsRaw)
|
||||||
|
? flagsRaw
|
||||||
|
: Object.entries(flagsRaw).map(([name, totals]) => ({ name, totals }));
|
||||||
|
|
||||||
|
const flagRows = [];
|
||||||
|
for (const entry of flagEntries) {
|
||||||
|
const label = entry.flag ?? entry.name ?? entry.component ?? entry.id;
|
||||||
|
const entryTotals = entry.totals ?? entry;
|
||||||
|
const entryCoverage = toNumber(entryTotals?.coverage);
|
||||||
|
const entryDelta = toNumber(
|
||||||
|
entryTotals?.coverage_change ??
|
||||||
|
entryTotals?.coverage_diff ??
|
||||||
|
entryTotals?.delta ??
|
||||||
|
entryTotals?.diff,
|
||||||
|
);
|
||||||
|
if (!label || entryCoverage === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
flagRows.push(`| ${label} | ${formatPercent(entryCoverage)} | ${formatDelta(entryDelta)} |`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flagRows.length) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('| Flag | Coverage | Change |');
|
||||||
|
lines.push('| --- | --- | --- |');
|
||||||
|
lines.push(...flagRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentBody = lines.join('\n');
|
||||||
|
const shouldComment = coverage !== undefined;
|
||||||
|
core.setOutput('shouldComment', shouldComment ? 'true' : 'false');
|
||||||
|
if (shouldComment) {
|
||||||
|
core.setOutput('commentBody', commentBody);
|
||||||
|
}
|
||||||
|
- name: Upsert coverage comment
|
||||||
|
if: steps.pr.outputs.shouldRun == 'true' && steps.coverage.outputs.shouldComment == 'true'
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
env:
|
||||||
|
PR_NUMBER: ${{ steps.pr.outputs.prNumber }}
|
||||||
|
COMMENT_BODY: ${{ steps.coverage.outputs.commentBody }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const prNumber = Number(process.env.PR_NUMBER);
|
||||||
|
const body = process.env.COMMENT_BODY;
|
||||||
|
const marker = '<!-- codecov-coverage-comment -->';
|
||||||
|
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const existing = comments.find((comment) => comment.body?.includes(marker));
|
||||||
|
if (existing) {
|
||||||
|
core.info(`Updating existing coverage comment (id: ${existing.id}).`);
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: existing.id,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
core.info('Creating new coverage comment.');
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
@@ -177,16 +177,10 @@ export class CustomFieldEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public removeSelectOption(index: number) {
|
public removeSelectOption(index: number) {
|
||||||
const globalIndex =
|
this.selectOptions.removeAt(index)
|
||||||
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE
|
this._allSelectOptions.splice(
|
||||||
this._allSelectOptions.splice(globalIndex, 1)
|
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE,
|
||||||
|
1
|
||||||
const totalPages = Math.max(
|
|
||||||
1,
|
|
||||||
Math.ceil(this._allSelectOptions.length / SELECT_OPTION_PAGE_SIZE)
|
|
||||||
)
|
)
|
||||||
const targetPage = Math.min(this.selectOptionsPage, totalPages)
|
|
||||||
|
|
||||||
this.selectOptionsPage = targetPage
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user