github-backup 0.56.0__tar.gz → 0.57.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {github_backup-0.56.0 → github_backup-0.57.0}/CHANGES.rst +32 -1
  2. {github_backup-0.56.0/github_backup.egg-info → github_backup-0.57.0}/PKG-INFO +40 -3
  3. {github_backup-0.56.0 → github_backup-0.57.0}/README +39 -2
  4. {github_backup-0.56.0 → github_backup-0.57.0}/README.rst +39 -2
  5. github_backup-0.57.0/github_backup/__init__.py +1 -0
  6. {github_backup-0.56.0 → github_backup-0.57.0}/github_backup/github_backup.py +72 -33
  7. {github_backup-0.56.0 → github_backup-0.57.0/github_backup.egg-info}/PKG-INFO +40 -3
  8. {github_backup-0.56.0 → github_backup-0.57.0}/github_backup.egg-info/SOURCES.txt +2 -1
  9. github_backup-0.57.0/tests/test_skip_assets_on.py +320 -0
  10. github_backup-0.56.0/github_backup/__init__.py +0 -1
  11. {github_backup-0.56.0 → github_backup-0.57.0}/LICENSE.txt +0 -0
  12. {github_backup-0.56.0 → github_backup-0.57.0}/MANIFEST.in +0 -0
  13. {github_backup-0.56.0 → github_backup-0.57.0}/github_backup/__main__.py +0 -0
  14. {github_backup-0.56.0 → github_backup-0.57.0}/github_backup/cli.py +0 -0
  15. {github_backup-0.56.0 → github_backup-0.57.0}/github_backup.egg-info/dependency_links.txt +0 -0
  16. {github_backup-0.56.0 → github_backup-0.57.0}/github_backup.egg-info/entry_points.txt +0 -0
  17. {github_backup-0.56.0 → github_backup-0.57.0}/github_backup.egg-info/top_level.txt +0 -0
  18. {github_backup-0.56.0 → github_backup-0.57.0}/github_backup.egg-info/zip-safe +0 -0
  19. {github_backup-0.56.0 → github_backup-0.57.0}/release-requirements.txt +0 -0
  20. {github_backup-0.56.0 → github_backup-0.57.0}/requirements.txt +0 -0
  21. {github_backup-0.56.0 → github_backup-0.57.0}/setup.cfg +0 -0
  22. {github_backup-0.56.0 → github_backup-0.57.0}/setup.py +0 -0
  23. {github_backup-0.56.0 → github_backup-0.57.0}/tests/test_all_starred.py +0 -0
  24. {github_backup-0.56.0 → github_backup-0.57.0}/tests/test_attachments.py +0 -0
  25. {github_backup-0.56.0 → github_backup-0.57.0}/tests/test_case_sensitivity.py +0 -0
  26. {github_backup-0.56.0 → github_backup-0.57.0}/tests/test_http_451.py +0 -0
  27. {github_backup-0.56.0 → github_backup-0.57.0}/tests/test_json_dump_if_changed.py +0 -0
  28. {github_backup-0.56.0 → github_backup-0.57.0}/tests/test_pagination.py +0 -0
@@ -1,9 +1,40 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
- 0.56.0 (2025-12-11)
4
+ 0.57.0 (2025-12-12)
5
5
  -------------------
6
6
  ------------------------
7
+ - Add GitHub Apps documentation and remove outdated header. [Rodos]
8
+
9
+ - Add GitHub Apps authentication section with setup steps
10
+ and CI/CD workflow example using actions/create-github-app-token
11
+ - Remove outdated machine-man-preview header (graduated 2020)
12
+
13
+ Closes #189
14
+ - Docs: add stdin token example to README. [Rodos]
15
+
16
+ Add example showing how to pipe a token from stdin using
17
+ file:///dev/stdin to avoid storing tokens in environment
18
+ variables or command history.
19
+
20
+ Closes #187
21
+ - Add --skip-assets-on flag to skip release asset downloads (#135)
22
+ [Rodos]
23
+
24
+ Allow users to skip downloading release assets for specific repositories
25
+ while still backing up release metadata. Useful for starred repos with
26
+ large assets (e.g. syncthing with 27GB+).
27
+
28
+ Usage: --skip-assets-on repo1 repo2 owner/repo3
29
+
30
+ Features:
31
+ - Space-separated repos (consistent with --exclude)
32
+ - Case-insensitive matching
33
+ - Supports both repo name and owner/repo format
34
+
35
+
36
+ 0.56.0 (2025-12-11)
37
+ -------------------
7
38
 
8
39
  Fix
9
40
  ~~~
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.56.0
3
+ Version: 0.57.0
4
4
  Summary: backup a github user or organization
5
5
  Home-page: http://github.com/josegonzalez/python-github-backup
6
6
  Author: Jose Diaz-Gonzalez
@@ -80,8 +80,8 @@ CLI Help output::
80
80
  [--keychain-name OSX_KEYCHAIN_ITEM_NAME]
81
81
  [--keychain-account OSX_KEYCHAIN_ITEM_ACCOUNT]
82
82
  [--releases] [--latest-releases NUMBER_OF_LATEST_RELEASES]
83
- [--skip-prerelease] [--assets] [--attachments]
84
- [--exclude [REPOSITORY [REPOSITORY ...]]
83
+ [--skip-prerelease] [--assets] [--skip-assets-on [REPO ...]]
84
+ [--attachments] [--exclude [REPOSITORY [REPOSITORY ...]]
85
85
  [--throttle-limit THROTTLE_LIMIT] [--throttle-pause THROTTLE_PAUSE]
86
86
  USER
87
87
 
@@ -163,6 +163,9 @@ CLI Help output::
163
163
  --skip-prerelease skip prerelease and draft versions; only applies if including releases
164
164
  --assets include assets alongside release information; only
165
165
  applies if including releases
166
+ --skip-assets-on [REPO ...]
167
+ skip asset downloads for these repositories (e.g.
168
+ --skip-assets-on repo1 owner/repo2)
166
169
  --attachments download user-attachments from issues and pull requests
167
170
  to issues/attachments/{issue_number}/ and
168
171
  pulls/attachments/{pull_number}/ directories
@@ -204,6 +207,37 @@ Customise the permissions for your use case, but for a personal account full bac
204
207
  **Repository permissions**: Read access to contents, issues, metadata, pull requests, and webhooks.
205
208
 
206
209
 
210
+ GitHub Apps
211
+ ~~~~~~~~~~~
212
+
213
+ GitHub Apps are ideal for organization backups in CI/CD. Tokens are scoped to specific repositories and expire after 1 hour.
214
+
215
+ **One-time setup:**
216
+
217
+ 1. Create a GitHub App at *Settings -> Developer Settings -> GitHub Apps -> New GitHub App*
218
+ 2. Set a name and homepage URL (can be any URL)
219
+ 3. Uncheck "Webhook > Active" (not needed for backups)
220
+ 4. Set permissions (same as fine-grained tokens above)
221
+ 5. Click "Create GitHub App", then note the **App ID** shown on the next page
222
+ 6. Under "Private keys", click "Generate a private key" and save the downloaded file
223
+ 7. Go to *Install App* in your app's settings
224
+ 8. Select the account/organization and which repositories to back up
225
+
226
+ **CI/CD usage with GitHub Actions:**
227
+
228
+ Store the App ID as a repository variable and the private key contents as a secret, then use ``actions/create-github-app-token``::
229
+
230
+ - uses: actions/create-github-app-token@v1
231
+ id: app-token
232
+ with:
233
+ app-id: ${{ vars.APP_ID }}
234
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
235
+
236
+ - run: github-backup myorg -t ${{ steps.app-token.outputs.token }} --as-app -o ./backup --all
237
+
238
+ Note: Installation tokens expire after 1 hour. For long-running backups, use a fine-grained personal access token instead.
239
+
240
+
207
241
  Prefer SSH
208
242
  ~~~~~~~~~~
209
243
 
@@ -391,6 +425,9 @@ Debug an error/block or incomplete backup into a temporary directory. Omit "incr
391
425
 
392
426
  github-backup -f $FINE_ACCESS_TOKEN -o /tmp/github-backup/ -l debug -P --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --repositories --wikis --releases --assets --pull-details --gists --starred-gists $GH_USER
393
427
 
428
+ Pipe a token from stdin to avoid storing it in environment variables or command history (Unix-like systems only)::
429
+
430
+ my-secret-manager get github-token | github-backup user -t file:///dev/stdin -o /backup --repositories
394
431
 
395
432
  Restoring from Backup
396
433
  =====================
@@ -50,8 +50,8 @@ CLI Help output::
50
50
  [--keychain-name OSX_KEYCHAIN_ITEM_NAME]
51
51
  [--keychain-account OSX_KEYCHAIN_ITEM_ACCOUNT]
52
52
  [--releases] [--latest-releases NUMBER_OF_LATEST_RELEASES]
53
- [--skip-prerelease] [--assets] [--attachments]
54
- [--exclude [REPOSITORY [REPOSITORY ...]]
53
+ [--skip-prerelease] [--assets] [--skip-assets-on [REPO ...]]
54
+ [--attachments] [--exclude [REPOSITORY [REPOSITORY ...]]
55
55
  [--throttle-limit THROTTLE_LIMIT] [--throttle-pause THROTTLE_PAUSE]
56
56
  USER
57
57
 
@@ -133,6 +133,9 @@ CLI Help output::
133
133
  --skip-prerelease skip prerelease and draft versions; only applies if including releases
134
134
  --assets include assets alongside release information; only
135
135
  applies if including releases
136
+ --skip-assets-on [REPO ...]
137
+ skip asset downloads for these repositories (e.g.
138
+ --skip-assets-on repo1 owner/repo2)
136
139
  --attachments download user-attachments from issues and pull requests
137
140
  to issues/attachments/{issue_number}/ and
138
141
  pulls/attachments/{pull_number}/ directories
@@ -174,6 +177,37 @@ Customise the permissions for your use case, but for a personal account full bac
174
177
  **Repository permissions**: Read access to contents, issues, metadata, pull requests, and webhooks.
175
178
 
176
179
 
180
+ GitHub Apps
181
+ ~~~~~~~~~~~
182
+
183
+ GitHub Apps are ideal for organization backups in CI/CD. Tokens are scoped to specific repositories and expire after 1 hour.
184
+
185
+ **One-time setup:**
186
+
187
+ 1. Create a GitHub App at *Settings -> Developer Settings -> GitHub Apps -> New GitHub App*
188
+ 2. Set a name and homepage URL (can be any URL)
189
+ 3. Uncheck "Webhook > Active" (not needed for backups)
190
+ 4. Set permissions (same as fine-grained tokens above)
191
+ 5. Click "Create GitHub App", then note the **App ID** shown on the next page
192
+ 6. Under "Private keys", click "Generate a private key" and save the downloaded file
193
+ 7. Go to *Install App* in your app's settings
194
+ 8. Select the account/organization and which repositories to back up
195
+
196
+ **CI/CD usage with GitHub Actions:**
197
+
198
+ Store the App ID as a repository variable and the private key contents as a secret, then use ``actions/create-github-app-token``::
199
+
200
+ - uses: actions/create-github-app-token@v1
201
+ id: app-token
202
+ with:
203
+ app-id: ${{ vars.APP_ID }}
204
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
205
+
206
+ - run: github-backup myorg -t ${{ steps.app-token.outputs.token }} --as-app -o ./backup --all
207
+
208
+ Note: Installation tokens expire after 1 hour. For long-running backups, use a fine-grained personal access token instead.
209
+
210
+
177
211
  Prefer SSH
178
212
  ~~~~~~~~~~
179
213
 
@@ -361,6 +395,9 @@ Debug an error/block or incomplete backup into a temporary directory. Omit "incr
361
395
 
362
396
  github-backup -f $FINE_ACCESS_TOKEN -o /tmp/github-backup/ -l debug -P --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --repositories --wikis --releases --assets --pull-details --gists --starred-gists $GH_USER
363
397
 
398
+ Pipe a token from stdin to avoid storing it in environment variables or command history (Unix-like systems only)::
399
+
400
+ my-secret-manager get github-token | github-backup user -t file:///dev/stdin -o /backup --repositories
364
401
 
365
402
  Restoring from Backup
366
403
  =====================
@@ -50,8 +50,8 @@ CLI Help output::
50
50
  [--keychain-name OSX_KEYCHAIN_ITEM_NAME]
51
51
  [--keychain-account OSX_KEYCHAIN_ITEM_ACCOUNT]
52
52
  [--releases] [--latest-releases NUMBER_OF_LATEST_RELEASES]
53
- [--skip-prerelease] [--assets] [--attachments]
54
- [--exclude [REPOSITORY [REPOSITORY ...]]
53
+ [--skip-prerelease] [--assets] [--skip-assets-on [REPO ...]]
54
+ [--attachments] [--exclude [REPOSITORY [REPOSITORY ...]]
55
55
  [--throttle-limit THROTTLE_LIMIT] [--throttle-pause THROTTLE_PAUSE]
56
56
  USER
57
57
 
@@ -133,6 +133,9 @@ CLI Help output::
133
133
  --skip-prerelease skip prerelease and draft versions; only applies if including releases
134
134
  --assets include assets alongside release information; only
135
135
  applies if including releases
136
+ --skip-assets-on [REPO ...]
137
+ skip asset downloads for these repositories (e.g.
138
+ --skip-assets-on repo1 owner/repo2)
136
139
  --attachments download user-attachments from issues and pull requests
137
140
  to issues/attachments/{issue_number}/ and
138
141
  pulls/attachments/{pull_number}/ directories
@@ -174,6 +177,37 @@ Customise the permissions for your use case, but for a personal account full bac
174
177
  **Repository permissions**: Read access to contents, issues, metadata, pull requests, and webhooks.
175
178
 
176
179
 
180
+ GitHub Apps
181
+ ~~~~~~~~~~~
182
+
183
+ GitHub Apps are ideal for organization backups in CI/CD. Tokens are scoped to specific repositories and expire after 1 hour.
184
+
185
+ **One-time setup:**
186
+
187
+ 1. Create a GitHub App at *Settings -> Developer Settings -> GitHub Apps -> New GitHub App*
188
+ 2. Set a name and homepage URL (can be any URL)
189
+ 3. Uncheck "Webhook > Active" (not needed for backups)
190
+ 4. Set permissions (same as fine-grained tokens above)
191
+ 5. Click "Create GitHub App", then note the **App ID** shown on the next page
192
+ 6. Under "Private keys", click "Generate a private key" and save the downloaded file
193
+ 7. Go to *Install App* in your app's settings
194
+ 8. Select the account/organization and which repositories to back up
195
+
196
+ **CI/CD usage with GitHub Actions:**
197
+
198
+ Store the App ID as a repository variable and the private key contents as a secret, then use ``actions/create-github-app-token``::
199
+
200
+ - uses: actions/create-github-app-token@v1
201
+ id: app-token
202
+ with:
203
+ app-id: ${{ vars.APP_ID }}
204
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
205
+
206
+ - run: github-backup myorg -t ${{ steps.app-token.outputs.token }} --as-app -o ./backup --all
207
+
208
+ Note: Installation tokens expire after 1 hour. For long-running backups, use a fine-grained personal access token instead.
209
+
210
+
177
211
  Prefer SSH
178
212
  ~~~~~~~~~~
179
213
 
@@ -361,6 +395,9 @@ Debug an error/block or incomplete backup into a temporary directory. Omit "incr
361
395
 
362
396
  github-backup -f $FINE_ACCESS_TOKEN -o /tmp/github-backup/ -l debug -P --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --repositories --wikis --releases --assets --pull-details --gists --starred-gists $GH_USER
363
397
 
398
+ Pipe a token from stdin to avoid storing it in environment variables or command history (Unix-like systems only)::
399
+
400
+ my-secret-manager get github-token | github-backup user -t file:///dev/stdin -o /backup --repositories
364
401
 
365
402
  Restoring from Backup
366
403
  =====================
@@ -0,0 +1 @@
1
+ __version__ = "0.57.0"
@@ -440,6 +440,12 @@ def parse_args(args=None):
440
440
  dest="include_assets",
441
441
  help="include assets alongside release information; only applies if including releases",
442
442
  )
443
+ parser.add_argument(
444
+ "--skip-assets-on",
445
+ dest="skip_assets_on",
446
+ nargs="*",
447
+ help="skip asset downloads for these repositories",
448
+ )
443
449
  parser.add_argument(
444
450
  "--attachments",
445
451
  action="store_true",
@@ -561,7 +567,7 @@ def get_github_host(args):
561
567
 
562
568
 
563
569
  def read_file_contents(file_uri):
564
- return open(file_uri[len(FILE_URI_PREFIX):], "rt").readline().strip()
570
+ return open(file_uri[len(FILE_URI_PREFIX) :], "rt").readline().strip()
565
571
 
566
572
 
567
573
  def get_github_repo_url(args, repository):
@@ -631,7 +637,7 @@ def retrieve_data_gen(args, template, query_args=None, single_request=False):
631
637
  pass
632
638
  raise RepositoryUnavailableError(
633
639
  "Repository unavailable due to legal reasons (HTTP 451)",
634
- dmca_url=dmca_url
640
+ dmca_url=dmca_url,
635
641
  )
636
642
 
637
643
  # Check if we got correct data
@@ -709,7 +715,7 @@ def retrieve_data_gen(args, template, query_args=None, single_request=False):
709
715
  # Parse Link header: <https://api.github.com/...?per_page=100&after=cursor>; rel="next"
710
716
  for link in link_header.split(","):
711
717
  if 'rel="next"' in link:
712
- next_url = link[link.find("<") + 1:link.find(">")]
718
+ next_url = link[link.find("<") + 1 : link.find(">")]
713
719
  break
714
720
  if not next_url:
715
721
  break
@@ -763,9 +769,7 @@ def _get_response(request, auth, template):
763
769
  return r, errors
764
770
 
765
771
 
766
- def _construct_request(
767
- per_page, query_args, template, auth, as_app=None, fine=False
768
- ):
772
+ def _construct_request(per_page, query_args, template, auth, as_app=None, fine=False):
769
773
  # If template is already a full URL with query params (from Link header), use it directly
770
774
  if "?" in template and template.startswith("http"):
771
775
  request_url = template
@@ -796,9 +800,6 @@ def _construct_request(
796
800
  else:
797
801
  auth = auth.encode("ascii")
798
802
  request.add_header("Authorization", "token ".encode("ascii") + auth)
799
- request.add_header(
800
- "Accept", "application/vnd.github.machine-man-preview+json"
801
- )
802
803
 
803
804
  log_url = template if "?" not in template else template.split("?")[0]
804
805
  if querystring:
@@ -1480,9 +1481,11 @@ def download_attachments(
1480
1481
  manifest = {
1481
1482
  "issue_number": number,
1482
1483
  "issue_type": item_type,
1483
- "repository": f"{args.user}/{args.repository}"
1484
- if hasattr(args, "repository") and args.repository
1485
- else args.user,
1484
+ "repository": (
1485
+ f"{args.user}/{args.repository}"
1486
+ if hasattr(args, "repository") and args.repository
1487
+ else args.user
1488
+ ),
1486
1489
  "manifest_updated_at": datetime.now(timezone.utc).isoformat(),
1487
1490
  "attachments": attachment_metadata_list,
1488
1491
  }
@@ -1538,9 +1541,7 @@ def retrieve_repositories(args, authenticated_user):
1538
1541
  else:
1539
1542
  repo_path = "{0}/{1}".format(args.user, args.repository)
1540
1543
  single_request = True
1541
- template = "https://{0}/repos/{1}".format(
1542
- get_github_api_host(args), repo_path
1543
- )
1544
+ template = "https://{0}/repos/{1}".format(get_github_api_host(args), repo_path)
1544
1545
 
1545
1546
  repos = retrieve_data(args, template, single_request=single_request)
1546
1547
 
@@ -1565,7 +1566,10 @@ def retrieve_repositories(args, authenticated_user):
1565
1566
  repos.extend(gists)
1566
1567
 
1567
1568
  if args.include_starred_gists:
1568
- if not authenticated_user.get("login") or args.user.lower() != authenticated_user["login"].lower():
1569
+ if (
1570
+ not authenticated_user.get("login")
1571
+ or args.user.lower() != authenticated_user["login"].lower()
1572
+ ):
1569
1573
  logger.warning(
1570
1574
  "Cannot retrieve starred gists for '%s'. GitHub only allows access to the authenticated user's starred gists.",
1571
1575
  args.user,
@@ -1673,9 +1677,11 @@ def backup_repositories(args, output_directory, repositories):
1673
1677
 
1674
1678
  include_gists = args.include_gists or args.include_starred_gists
1675
1679
  include_starred = args.all_starred and repository.get("is_starred")
1676
- if (args.include_repository or args.include_everything) or (
1677
- include_gists and repository.get("is_gist")
1678
- ) or include_starred:
1680
+ if (
1681
+ (args.include_repository or args.include_everything)
1682
+ or (include_gists and repository.get("is_gist"))
1683
+ or include_starred
1684
+ ):
1679
1685
  repo_name = (
1680
1686
  repository.get("name")
1681
1687
  if not repository.get("is_gist")
@@ -1735,7 +1741,9 @@ def backup_repositories(args, output_directory, repositories):
1735
1741
  include_assets=args.include_assets or args.include_everything,
1736
1742
  )
1737
1743
  except RepositoryUnavailableError as e:
1738
- logger.warning(f"Repository {repository['full_name']} is unavailable (HTTP 451)")
1744
+ logger.warning(
1745
+ f"Repository {repository['full_name']} is unavailable (HTTP 451)"
1746
+ )
1739
1747
  if e.dmca_url:
1740
1748
  logger.warning(f"DMCA notice: {e.dmca_url}")
1741
1749
  logger.info(f"Skipping remaining resources for {repository['full_name']}")
@@ -1795,7 +1803,11 @@ def backup_issues(args, repo_cwd, repository, repos_template):
1795
1803
  modified = os.path.getmtime(issue_file)
1796
1804
  modified = datetime.fromtimestamp(modified).strftime("%Y-%m-%dT%H:%M:%SZ")
1797
1805
  if modified > issue["updated_at"]:
1798
- logger.info("Skipping issue {0} because it wasn't modified since last backup".format(number))
1806
+ logger.info(
1807
+ "Skipping issue {0} because it wasn't modified since last backup".format(
1808
+ number
1809
+ )
1810
+ )
1799
1811
  continue
1800
1812
 
1801
1813
  if args.include_issue_comments or args.include_everything:
@@ -1869,7 +1881,11 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
1869
1881
  modified = os.path.getmtime(pull_file)
1870
1882
  modified = datetime.fromtimestamp(modified).strftime("%Y-%m-%dT%H:%M:%SZ")
1871
1883
  if modified > pull["updated_at"]:
1872
- logger.info("Skipping pull request {0} because it wasn't modified since last backup".format(number))
1884
+ logger.info(
1885
+ "Skipping pull request {0} because it wasn't modified since last backup".format(
1886
+ number
1887
+ )
1888
+ )
1873
1889
  continue
1874
1890
  if args.include_pull_comments or args.include_everything:
1875
1891
  template = comments_regular_template.format(number)
@@ -1919,9 +1935,11 @@ def backup_milestones(args, repo_cwd, repository, repos_template):
1919
1935
  elif written_count == 0:
1920
1936
  logger.info("{0} milestones unchanged, skipped write".format(total))
1921
1937
  else:
1922
- logger.info("Saved {0} of {1} milestones to disk ({2} unchanged)".format(
1923
- written_count, total, total - written_count
1924
- ))
1938
+ logger.info(
1939
+ "Saved {0} of {1} milestones to disk ({2} unchanged)".format(
1940
+ written_count, total, total - written_count
1941
+ )
1942
+ )
1925
1943
 
1926
1944
 
1927
1945
  def backup_labels(args, repo_cwd, repository, repos_template):
@@ -1975,6 +1993,20 @@ def backup_releases(args, repo_cwd, repository, repos_template, include_assets=F
1975
1993
  )
1976
1994
  releases = releases[: args.number_of_latest_releases]
1977
1995
 
1996
+ # Check if this repo should skip asset downloads (case-insensitive)
1997
+ skip_assets = False
1998
+ if include_assets:
1999
+ repo_name = repository.get("name", "").lower()
2000
+ repo_full_name = repository.get("full_name", "").lower()
2001
+ skip_repos = [r.lower() for r in (args.skip_assets_on or [])]
2002
+ skip_assets = repo_name in skip_repos or repo_full_name in skip_repos
2003
+ if skip_assets:
2004
+ logger.info(
2005
+ "Skipping assets for {0} ({1} releases) due to --skip-assets-on".format(
2006
+ repository.get("name"), len(releases)
2007
+ )
2008
+ )
2009
+
1978
2010
  # for each release, store it
1979
2011
  written_count = 0
1980
2012
  for release in releases:
@@ -1986,7 +2018,7 @@ def backup_releases(args, repo_cwd, repository, repos_template, include_assets=F
1986
2018
  if json_dump_if_changed(release, output_filepath):
1987
2019
  written_count += 1
1988
2020
 
1989
- if include_assets:
2021
+ if include_assets and not skip_assets:
1990
2022
  assets = retrieve_data(args, release["assets_url"])
1991
2023
  if len(assets) > 0:
1992
2024
  # give release asset files somewhere to live & download them (not including source archives)
@@ -2008,9 +2040,11 @@ def backup_releases(args, repo_cwd, repository, repos_template, include_assets=F
2008
2040
  elif written_count == 0:
2009
2041
  logger.info("{0} releases unchanged, skipped write".format(total))
2010
2042
  else:
2011
- logger.info("Saved {0} of {1} releases to disk ({2} unchanged)".format(
2012
- written_count, total, total - written_count
2013
- ))
2043
+ logger.info(
2044
+ "Saved {0} of {1} releases to disk ({2} unchanged)".format(
2045
+ written_count, total, total - written_count
2046
+ )
2047
+ )
2014
2048
 
2015
2049
 
2016
2050
  def fetch_repository(
@@ -2024,9 +2058,12 @@ def fetch_repository(
2024
2058
  ):
2025
2059
  if bare_clone:
2026
2060
  if os.path.exists(local_dir):
2027
- clone_exists = subprocess.check_output(
2028
- ["git", "rev-parse", "--is-bare-repository"], cwd=local_dir
2029
- ) == b"true\n"
2061
+ clone_exists = (
2062
+ subprocess.check_output(
2063
+ ["git", "rev-parse", "--is-bare-repository"], cwd=local_dir
2064
+ )
2065
+ == b"true\n"
2066
+ )
2030
2067
  else:
2031
2068
  clone_exists = False
2032
2069
  else:
@@ -2047,7 +2084,9 @@ def fetch_repository(
2047
2084
  )
2048
2085
  else:
2049
2086
  logger.info(
2050
- "Skipping {0} (repository not accessible - may be empty, private, or credentials invalid)".format(name)
2087
+ "Skipping {0} (repository not accessible - may be empty, private, or credentials invalid)".format(
2088
+ name
2089
+ )
2051
2090
  )
2052
2091
  return
2053
2092
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.56.0
3
+ Version: 0.57.0
4
4
  Summary: backup a github user or organization
5
5
  Home-page: http://github.com/josegonzalez/python-github-backup
6
6
  Author: Jose Diaz-Gonzalez
@@ -80,8 +80,8 @@ CLI Help output::
80
80
  [--keychain-name OSX_KEYCHAIN_ITEM_NAME]
81
81
  [--keychain-account OSX_KEYCHAIN_ITEM_ACCOUNT]
82
82
  [--releases] [--latest-releases NUMBER_OF_LATEST_RELEASES]
83
- [--skip-prerelease] [--assets] [--attachments]
84
- [--exclude [REPOSITORY [REPOSITORY ...]]
83
+ [--skip-prerelease] [--assets] [--skip-assets-on [REPO ...]]
84
+ [--attachments] [--exclude [REPOSITORY [REPOSITORY ...]]
85
85
  [--throttle-limit THROTTLE_LIMIT] [--throttle-pause THROTTLE_PAUSE]
86
86
  USER
87
87
 
@@ -163,6 +163,9 @@ CLI Help output::
163
163
  --skip-prerelease skip prerelease and draft versions; only applies if including releases
164
164
  --assets include assets alongside release information; only
165
165
  applies if including releases
166
+ --skip-assets-on [REPO ...]
167
+ skip asset downloads for these repositories (e.g.
168
+ --skip-assets-on repo1 owner/repo2)
166
169
  --attachments download user-attachments from issues and pull requests
167
170
  to issues/attachments/{issue_number}/ and
168
171
  pulls/attachments/{pull_number}/ directories
@@ -204,6 +207,37 @@ Customise the permissions for your use case, but for a personal account full bac
204
207
  **Repository permissions**: Read access to contents, issues, metadata, pull requests, and webhooks.
205
208
 
206
209
 
210
+ GitHub Apps
211
+ ~~~~~~~~~~~
212
+
213
+ GitHub Apps are ideal for organization backups in CI/CD. Tokens are scoped to specific repositories and expire after 1 hour.
214
+
215
+ **One-time setup:**
216
+
217
+ 1. Create a GitHub App at *Settings -> Developer Settings -> GitHub Apps -> New GitHub App*
218
+ 2. Set a name and homepage URL (can be any URL)
219
+ 3. Uncheck "Webhook > Active" (not needed for backups)
220
+ 4. Set permissions (same as fine-grained tokens above)
221
+ 5. Click "Create GitHub App", then note the **App ID** shown on the next page
222
+ 6. Under "Private keys", click "Generate a private key" and save the downloaded file
223
+ 7. Go to *Install App* in your app's settings
224
+ 8. Select the account/organization and which repositories to back up
225
+
226
+ **CI/CD usage with GitHub Actions:**
227
+
228
+ Store the App ID as a repository variable and the private key contents as a secret, then use ``actions/create-github-app-token``::
229
+
230
+ - uses: actions/create-github-app-token@v1
231
+ id: app-token
232
+ with:
233
+ app-id: ${{ vars.APP_ID }}
234
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
235
+
236
+ - run: github-backup myorg -t ${{ steps.app-token.outputs.token }} --as-app -o ./backup --all
237
+
238
+ Note: Installation tokens expire after 1 hour. For long-running backups, use a fine-grained personal access token instead.
239
+
240
+
207
241
  Prefer SSH
208
242
  ~~~~~~~~~~
209
243
 
@@ -391,6 +425,9 @@ Debug an error/block or incomplete backup into a temporary directory. Omit "incr
391
425
 
392
426
  github-backup -f $FINE_ACCESS_TOKEN -o /tmp/github-backup/ -l debug -P --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --repositories --wikis --releases --assets --pull-details --gists --starred-gists $GH_USER
393
427
 
428
+ Pipe a token from stdin to avoid storing it in environment variables or command history (Unix-like systems only)::
429
+
430
+ my-secret-manager get github-token | github-backup user -t file:///dev/stdin -o /backup --repositories
394
431
 
395
432
  Restoring from Backup
396
433
  =====================
@@ -21,4 +21,5 @@ tests/test_attachments.py
21
21
  tests/test_case_sensitivity.py
22
22
  tests/test_http_451.py
23
23
  tests/test_json_dump_if_changed.py
24
- tests/test_pagination.py
24
+ tests/test_pagination.py
25
+ tests/test_skip_assets_on.py
@@ -0,0 +1,320 @@
1
+ """Tests for --skip-assets-on flag behavior (issue #135)."""
2
+
3
+ import pytest
4
+ from unittest.mock import Mock, patch
5
+
6
+ from github_backup import github_backup
7
+
8
+
9
+ class TestSkipAssetsOn:
10
+ """Test suite for --skip-assets-on flag.
11
+
12
+ Issue #135: Allow skipping asset downloads for specific repositories
13
+ while still backing up release metadata.
14
+ """
15
+
16
+ def _create_mock_args(self, **overrides):
17
+ """Create a mock args object with sensible defaults."""
18
+ args = Mock()
19
+ args.user = "testuser"
20
+ args.output_directory = "/tmp/backup"
21
+ args.include_repository = False
22
+ args.include_everything = False
23
+ args.include_gists = False
24
+ args.include_starred_gists = False
25
+ args.all_starred = False
26
+ args.skip_existing = False
27
+ args.bare_clone = False
28
+ args.lfs_clone = False
29
+ args.no_prune = False
30
+ args.include_wiki = False
31
+ args.include_issues = False
32
+ args.include_issue_comments = False
33
+ args.include_issue_events = False
34
+ args.include_pulls = False
35
+ args.include_pull_comments = False
36
+ args.include_pull_commits = False
37
+ args.include_pull_details = False
38
+ args.include_labels = False
39
+ args.include_hooks = False
40
+ args.include_milestones = False
41
+ args.include_releases = True
42
+ args.include_assets = True
43
+ args.skip_assets_on = []
44
+ args.include_attachments = False
45
+ args.incremental = False
46
+ args.incremental_by_files = False
47
+ args.github_host = None
48
+ args.prefer_ssh = False
49
+ args.token_classic = "test-token"
50
+ args.token_fine = None
51
+ args.username = None
52
+ args.password = None
53
+ args.as_app = False
54
+ args.osx_keychain_item_name = None
55
+ args.osx_keychain_item_account = None
56
+ args.skip_prerelease = False
57
+ args.number_of_latest_releases = None
58
+
59
+ for key, value in overrides.items():
60
+ setattr(args, key, value)
61
+
62
+ return args
63
+
64
+ def _create_mock_repository(self, name="test-repo", owner="testuser"):
65
+ """Create a mock repository object."""
66
+ return {
67
+ "name": name,
68
+ "full_name": f"{owner}/{name}",
69
+ "owner": {"login": owner},
70
+ "private": False,
71
+ "fork": False,
72
+ "has_wiki": False,
73
+ }
74
+
75
+ def _create_mock_release(self, tag="v1.0.0"):
76
+ """Create a mock release object."""
77
+ return {
78
+ "tag_name": tag,
79
+ "name": tag,
80
+ "prerelease": False,
81
+ "draft": False,
82
+ "assets_url": f"https://api.github.com/repos/testuser/test-repo/releases/{tag}/assets",
83
+ }
84
+
85
+ def _create_mock_asset(self, name="asset.zip"):
86
+ """Create a mock asset object."""
87
+ return {
88
+ "name": name,
89
+ "url": f"https://api.github.com/repos/testuser/test-repo/releases/assets/{name}",
90
+ }
91
+
92
+
93
+ class TestSkipAssetsOnArgumentParsing(TestSkipAssetsOn):
94
+ """Tests for --skip-assets-on argument parsing."""
95
+
96
+ def test_skip_assets_on_not_set_defaults_to_none(self):
97
+ """When --skip-assets-on is not specified, it should default to None."""
98
+ args = github_backup.parse_args(["testuser"])
99
+ assert args.skip_assets_on is None
100
+
101
+ def test_skip_assets_on_single_repo(self):
102
+ """Single --skip-assets-on should create list with one item."""
103
+ args = github_backup.parse_args(["testuser", "--skip-assets-on", "big-repo"])
104
+ assert args.skip_assets_on == ["big-repo"]
105
+
106
+ def test_skip_assets_on_multiple_repos(self):
107
+ """Multiple repos can be specified space-separated (like --exclude)."""
108
+ args = github_backup.parse_args(
109
+ [
110
+ "testuser",
111
+ "--skip-assets-on",
112
+ "big-repo",
113
+ "another-repo",
114
+ "owner/third-repo",
115
+ ]
116
+ )
117
+ assert args.skip_assets_on == ["big-repo", "another-repo", "owner/third-repo"]
118
+
119
+
120
+ class TestSkipAssetsOnBehavior(TestSkipAssetsOn):
121
+ """Tests for --skip-assets-on behavior in backup_releases."""
122
+
123
+ @patch("github_backup.github_backup.download_file")
124
+ @patch("github_backup.github_backup.retrieve_data")
125
+ @patch("github_backup.github_backup.mkdir_p")
126
+ @patch("github_backup.github_backup.json_dump_if_changed")
127
+ def test_assets_downloaded_when_not_skipped(
128
+ self, mock_json_dump, mock_mkdir, mock_retrieve, mock_download
129
+ ):
130
+ """Assets should be downloaded when repo is not in skip list."""
131
+ args = self._create_mock_args(skip_assets_on=[])
132
+ repository = self._create_mock_repository(name="normal-repo")
133
+ release = self._create_mock_release()
134
+ asset = self._create_mock_asset()
135
+
136
+ mock_json_dump.return_value = True
137
+ mock_retrieve.side_effect = [
138
+ [release], # First call: get releases
139
+ [asset], # Second call: get assets
140
+ ]
141
+
142
+ with patch("os.path.join", side_effect=lambda *args: "/".join(args)):
143
+ github_backup.backup_releases(
144
+ args,
145
+ "/tmp/backup/repositories/normal-repo",
146
+ repository,
147
+ "https://api.github.com/repos/{owner}/{repo}",
148
+ include_assets=True,
149
+ )
150
+
151
+ # download_file should have been called for the asset
152
+ mock_download.assert_called_once()
153
+
154
+ @patch("github_backup.github_backup.download_file")
155
+ @patch("github_backup.github_backup.retrieve_data")
156
+ @patch("github_backup.github_backup.mkdir_p")
157
+ @patch("github_backup.github_backup.json_dump_if_changed")
158
+ def test_assets_skipped_when_repo_name_matches(
159
+ self, mock_json_dump, mock_mkdir, mock_retrieve, mock_download
160
+ ):
161
+ """Assets should be skipped when repo name is in skip list."""
162
+ args = self._create_mock_args(skip_assets_on=["big-repo"])
163
+ repository = self._create_mock_repository(name="big-repo")
164
+ release = self._create_mock_release()
165
+
166
+ mock_json_dump.return_value = True
167
+ mock_retrieve.return_value = [release]
168
+
169
+ github_backup.backup_releases(
170
+ args,
171
+ "/tmp/backup/repositories/big-repo",
172
+ repository,
173
+ "https://api.github.com/repos/{owner}/{repo}",
174
+ include_assets=True,
175
+ )
176
+
177
+ # download_file should NOT have been called
178
+ mock_download.assert_not_called()
179
+
180
+ @patch("github_backup.github_backup.download_file")
181
+ @patch("github_backup.github_backup.retrieve_data")
182
+ @patch("github_backup.github_backup.mkdir_p")
183
+ @patch("github_backup.github_backup.json_dump_if_changed")
184
+ def test_assets_skipped_when_full_name_matches(
185
+ self, mock_json_dump, mock_mkdir, mock_retrieve, mock_download
186
+ ):
187
+ """Assets should be skipped when owner/repo format matches."""
188
+ args = self._create_mock_args(skip_assets_on=["otheruser/big-repo"])
189
+ repository = self._create_mock_repository(name="big-repo", owner="otheruser")
190
+ release = self._create_mock_release()
191
+
192
+ mock_json_dump.return_value = True
193
+ mock_retrieve.return_value = [release]
194
+
195
+ github_backup.backup_releases(
196
+ args,
197
+ "/tmp/backup/repositories/big-repo",
198
+ repository,
199
+ "https://api.github.com/repos/{owner}/{repo}",
200
+ include_assets=True,
201
+ )
202
+
203
+ # download_file should NOT have been called
204
+ mock_download.assert_not_called()
205
+
206
+ @patch("github_backup.github_backup.download_file")
207
+ @patch("github_backup.github_backup.retrieve_data")
208
+ @patch("github_backup.github_backup.mkdir_p")
209
+ @patch("github_backup.github_backup.json_dump_if_changed")
210
+ def test_case_insensitive_matching(
211
+ self, mock_json_dump, mock_mkdir, mock_retrieve, mock_download
212
+ ):
213
+ """Skip matching should be case-insensitive."""
214
+ # User types uppercase, repo name is lowercase
215
+ args = self._create_mock_args(skip_assets_on=["BIG-REPO"])
216
+ repository = self._create_mock_repository(name="big-repo")
217
+ release = self._create_mock_release()
218
+
219
+ mock_json_dump.return_value = True
220
+ mock_retrieve.return_value = [release]
221
+
222
+ github_backup.backup_releases(
223
+ args,
224
+ "/tmp/backup/repositories/big-repo",
225
+ repository,
226
+ "https://api.github.com/repos/{owner}/{repo}",
227
+ include_assets=True,
228
+ )
229
+
230
+ # download_file should NOT have been called (case-insensitive match)
231
+ assert not mock_download.called
232
+
233
+ @patch("github_backup.github_backup.download_file")
234
+ @patch("github_backup.github_backup.retrieve_data")
235
+ @patch("github_backup.github_backup.mkdir_p")
236
+ @patch("github_backup.github_backup.json_dump_if_changed")
237
+ def test_multiple_skip_repos(
238
+ self, mock_json_dump, mock_mkdir, mock_retrieve, mock_download
239
+ ):
240
+ """Multiple repos in skip list should all be skipped."""
241
+ args = self._create_mock_args(skip_assets_on=["repo1", "repo2", "repo3"])
242
+ repository = self._create_mock_repository(name="repo2")
243
+ release = self._create_mock_release()
244
+
245
+ mock_json_dump.return_value = True
246
+ mock_retrieve.return_value = [release]
247
+
248
+ github_backup.backup_releases(
249
+ args,
250
+ "/tmp/backup/repositories/repo2",
251
+ repository,
252
+ "https://api.github.com/repos/{owner}/{repo}",
253
+ include_assets=True,
254
+ )
255
+
256
+ # download_file should NOT have been called
257
+ mock_download.assert_not_called()
258
+
259
+ @patch("github_backup.github_backup.download_file")
260
+ @patch("github_backup.github_backup.retrieve_data")
261
+ @patch("github_backup.github_backup.mkdir_p")
262
+ @patch("github_backup.github_backup.json_dump_if_changed")
263
+ def test_release_metadata_still_saved_when_assets_skipped(
264
+ self, mock_json_dump, mock_mkdir, mock_retrieve, mock_download
265
+ ):
266
+ """Release JSON should still be saved even when assets are skipped."""
267
+ args = self._create_mock_args(skip_assets_on=["big-repo"])
268
+ repository = self._create_mock_repository(name="big-repo")
269
+ release = self._create_mock_release()
270
+
271
+ mock_json_dump.return_value = True
272
+ mock_retrieve.return_value = [release]
273
+
274
+ github_backup.backup_releases(
275
+ args,
276
+ "/tmp/backup/repositories/big-repo",
277
+ repository,
278
+ "https://api.github.com/repos/{owner}/{repo}",
279
+ include_assets=True,
280
+ )
281
+
282
+ # json_dump_if_changed should have been called for release metadata
283
+ mock_json_dump.assert_called_once()
284
+ # But download_file should NOT have been called
285
+ mock_download.assert_not_called()
286
+
287
+ @patch("github_backup.github_backup.download_file")
288
+ @patch("github_backup.github_backup.retrieve_data")
289
+ @patch("github_backup.github_backup.mkdir_p")
290
+ @patch("github_backup.github_backup.json_dump_if_changed")
291
+ def test_non_matching_repo_still_downloads_assets(
292
+ self, mock_json_dump, mock_mkdir, mock_retrieve, mock_download
293
+ ):
294
+ """Repos not in skip list should still download assets."""
295
+ args = self._create_mock_args(skip_assets_on=["other-repo"])
296
+ repository = self._create_mock_repository(name="normal-repo")
297
+ release = self._create_mock_release()
298
+ asset = self._create_mock_asset()
299
+
300
+ mock_json_dump.return_value = True
301
+ mock_retrieve.side_effect = [
302
+ [release], # First call: get releases
303
+ [asset], # Second call: get assets
304
+ ]
305
+
306
+ with patch("os.path.join", side_effect=lambda *args: "/".join(args)):
307
+ github_backup.backup_releases(
308
+ args,
309
+ "/tmp/backup/repositories/normal-repo",
310
+ repository,
311
+ "https://api.github.com/repos/{owner}/{repo}",
312
+ include_assets=True,
313
+ )
314
+
315
+ # download_file SHOULD have been called
316
+ mock_download.assert_called_once()
317
+
318
+
319
+ if __name__ == "__main__":
320
+ pytest.main([__file__, "-v"])
@@ -1 +0,0 @@
1
- __version__ = "0.56.0"
File without changes
File without changes